diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4ad78fa --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# 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-lock.json b/package-lock.json index 509d998..90c5b9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,20 +8,20 @@ "name": "omniai-web-preview", "version": "0.1.0", "dependencies": { - "@ant-design/icons": "^5.3.0", - "@xyflow/react": "^12.10.2", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "zustand": "^5.0.13" + "@ant-design/icons": "5.3.0", + "@xyflow/react": "12.10.2", + "react": "18.2.0", + "react-dom": "18.2.0", + "zustand": "5.0.13" }, "devDependencies": { - "@types/react": "^18.2.0", - "@types/react-dom": "^18.2.0", - "@vitejs/plugin-react": "^4.2.1", - "playwright": "^1.60.0", - "sharp": "^0.34.5", - "typescript": "^5.3.3", - "vite": "^5.1.0", + "@types/react": "18.2.0", + "@types/react-dom": "18.2.0", + "@vitejs/plugin-react": "4.2.1", + "playwright": "1.60.0", + "sharp": "0.34.5", + "typescript": "5.3.3", + "vite": "5.1.0", "vite-plugin-compression2": "2.5.3" } }, @@ -47,14 +47,14 @@ } }, "node_modules/@ant-design/icons": { - "version": "5.6.1", - "resolved": "https://registry.npmmirror.com/@ant-design/icons/-/icons-5.6.1.tgz", - "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.3.0.tgz", + "integrity": "sha512-69FgBsIkeCjw72ZU3fJpqjhmLCPrzKGEllbrAZK7MUdt1BrKsyG6A8YDCBPKea27UQ0tRXi33PcjR4tp/tEXMg==", "license": "MIT", "dependencies": { "@ant-design/colors": "^7.0.0", "@ant-design/icons-svg": "^4.4.0", - "@babel/runtime": "^7.24.8", + "@babel/runtime": "^7.11.2", "classnames": "^2.2.6", "rc-util": "^5.31.1" }, @@ -103,7 +103,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -376,9 +375,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", + "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", "cpu": [ "ppc64" ], @@ -393,9 +392,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", + "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", "cpu": [ "arm" ], @@ -410,9 +409,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", + "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", "cpu": [ "arm64" ], @@ -427,9 +426,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", + "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", "cpu": [ "x64" ], @@ -444,9 +443,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", + "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", "cpu": [ "arm64" ], @@ -461,9 +460,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", + "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", "cpu": [ "x64" ], @@ -478,9 +477,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", + "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", "cpu": [ "arm64" ], @@ -495,9 +494,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", + "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", "cpu": [ "x64" ], @@ -512,9 +511,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", + "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", "cpu": [ "arm" ], @@ -529,9 +528,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", + "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", "cpu": [ "arm64" ], @@ -546,9 +545,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", + "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", "cpu": [ "ia32" ], @@ -563,9 +562,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", + "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", "cpu": [ "loong64" ], @@ -580,9 +579,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", + "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", "cpu": [ "mips64el" ], @@ -597,9 +596,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", + "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", "cpu": [ "ppc64" ], @@ -614,9 +613,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", + "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", "cpu": [ "riscv64" ], @@ -631,9 +630,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", + "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", "cpu": [ "s390x" ], @@ -648,9 +647,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", + "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", "cpu": [ "x64" ], @@ -665,9 +664,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", + "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", "cpu": [ "x64" ], @@ -682,9 +681,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", + "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", "cpu": [ "x64" ], @@ -699,9 +698,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", + "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", "cpu": [ "x64" ], @@ -716,9 +715,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", + "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", "cpu": [ "arm64" ], @@ -733,9 +732,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", + "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", "cpu": [ "ia32" ], @@ -750,9 +749,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", + "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", "cpu": [ "x64" ], @@ -1306,13 +1305,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", - "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", - "dev": true, - "license": "MIT" - }, "node_modules/@rollup/pluginutils": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", @@ -1795,46 +1787,52 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.28", - "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.28.tgz", - "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.0.tgz", + "integrity": "sha512-0FLj93y5USLHdnhIhABk83rm8XEGA7kH3cr+YUlvxoUGp1xNt/DINUMvqPxLyOQMzLmZe8i4RTHbvb8MC7NmrA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", - "csstype": "^3.2.2" + "@types/scheduler": "*", + "csstype": "^3.0.2" } }, "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", - "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", - "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-8yQrvS6sMpSwIovhPOwfyNf2Wz6v/B62LFSVYQ85+Rq3tLsBIG7rP5geMxaijTUxSkrO6RzN/IRuIAADYQsleA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.28.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/react": "*" + } + }, + "node_modules/@types/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.2.1.tgz", + "integrity": "sha512-oojO9IDc4nCUUi8qIR11KoQm0XFFLIwsRBwHRR4d/88IWghn1y6ckz/bJ8GHDCsYEJee8mDzqtJxh15/cisJNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.5", + "@babel/plugin-transform-react-jsx-self": "^7.23.3", + "@babel/plugin-transform-react-jsx-source": "^7.23.3", "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" + "react-refresh": "^0.14.0" }, "engines": { "node": "^14.18.0 || >=16.0.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "vite": "^4.2.0 || ^5.0.0" } }, "node_modules/@xyflow/react": { @@ -1930,7 +1928,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2049,7 +2046,6 @@ "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -2134,9 +2130,9 @@ "license": "ISC" }, "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", + "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2147,29 +2143,29 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" + "@esbuild/aix-ppc64": "0.19.12", + "@esbuild/android-arm": "0.19.12", + "@esbuild/android-arm64": "0.19.12", + "@esbuild/android-x64": "0.19.12", + "@esbuild/darwin-arm64": "0.19.12", + "@esbuild/darwin-x64": "0.19.12", + "@esbuild/freebsd-arm64": "0.19.12", + "@esbuild/freebsd-x64": "0.19.12", + "@esbuild/linux-arm": "0.19.12", + "@esbuild/linux-arm64": "0.19.12", + "@esbuild/linux-ia32": "0.19.12", + "@esbuild/linux-loong64": "0.19.12", + "@esbuild/linux-mips64el": "0.19.12", + "@esbuild/linux-ppc64": "0.19.12", + "@esbuild/linux-riscv64": "0.19.12", + "@esbuild/linux-s390x": "0.19.12", + "@esbuild/linux-x64": "0.19.12", + "@esbuild/netbsd-x64": "0.19.12", + "@esbuild/openbsd-x64": "0.19.12", + "@esbuild/sunos-x64": "0.19.12", + "@esbuild/win32-arm64": "0.19.12", + "@esbuild/win32-ia32": "0.19.12", + "@esbuild/win32-x64": "0.19.12" } }, "node_modules/escalade": { @@ -2399,7 +2395,7 @@ }, "node_modules/rc-util": { "version": "5.44.4", - "resolved": "https://registry.npmmirror.com/rc-util/-/rc-util-5.44.4.tgz", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz", "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", "license": "MIT", "dependencies": { @@ -2412,11 +2408,10 @@ } }, "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2425,29 +2420,28 @@ } }, "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" + "scheduler": "^0.23.0" }, "peerDependencies": { - "react": "^18.3.1" + "react": "^18.2.0" } }, "node_modules/react-is": { "version": "18.3.1", - "resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "dev": true, "license": "MIT", "engines": { @@ -2602,9 +2596,9 @@ "optional": true }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2656,16 +2650,15 @@ } }, "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.0.tgz", + "integrity": "sha512-STmSFzhY4ljuhz14bg9LkMTk3d98IO6DIArnTY6MeBwiD1Za2StcQtz7fzOUnRCqrHSD5+OS2reg4HOz1eoLnw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "esbuild": "^0.19.3", + "postcss": "^8.4.35", + "rollup": "^4.2.0" }, "bin": { "vite": "bin/vite.js" @@ -2684,7 +2677,6 @@ "less": "*", "lightningcss": "^1.21.0", "sass": "*", - "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" @@ -2702,9 +2694,6 @@ "sass": { "optional": true }, - "sass-embedded": { - "optional": true - }, "stylus": { "optional": true }, 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/scripts/smoke-generation-mocked.mjs b/scripts/smoke-generation-mocked.mjs new file mode 100644 index 0000000..f078568 --- /dev/null +++ b/scripts/smoke-generation-mocked.mjs @@ -0,0 +1,72 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const repoRoot = process.cwd(); +const failures = []; + +function read(relativePath) { + return fs.readFileSync(path.join(repoRoot, relativePath), "utf8"); +} + +function assertMatch(label, content, pattern) { + if (!pattern.test(content)) { + failures.push(label); + } +} + +function assertNoMatch(label, content, pattern) { + if (pattern.test(content)) { + failures.push(label); + } +} + +const serverConnection = read("src/api/serverConnection.ts"); +const generationClient = read("src/api/aiGenerationClient.ts"); +const ecommerceVideoService = read("src/features/ecommerce/ecommerceVideoService.ts"); +const workbenchPersistence = read("src/features/workbench/workbenchResultPersistence.ts"); + +assertMatch( + "serverConnection must build same-origin /api URLs", + serverConnection, + /return\s+`\/api\/\$\{cleanPath\}`;/, +); +assertNoMatch( + "frontend generation flow must not use fixed VITE environment config", + `${serverConnection}\n${generationClient}`, + /\b(?:import\.meta\.env|VITE_[A-Z0-9_]+)\b/, +); +assertNoMatch( + "frontend generation flow must not call provider hosts directly", + generationClient, + /dashscope\.aliyuncs\.com|\/dashscope-api\b|Bearer\s+sk-/i, +); +assertMatch("image generation must go through the app API", generationClient, /buildApiUrl\("ai\/image"\)/); +assertMatch("video generation must go through the app API", generationClient, /buildApiUrl\("ai\/video"\)/); +assertMatch("binary uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-binary"\)/); +assertMatch("URL uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-by-url"\)/); +assertMatch( + "ecommerce video history must durable-copy media before saving", + ecommerceVideoService, + /buildDurableVideoHistoryPayload\(payload\)/, +); +assertMatch( + "ecommerce video history must filter temporary provider URLs on read", + ecommerceVideoService, + /items:\s*history\.items\.map\(removeTemporaryHistoryUrls\)/, +); +assertMatch( + "workbench results must persist generated media through OSS", + workbenchPersistence, + /uploadAssetByUrl\(/, +); + +if (failures.length) { + console.error("Mocked generation smoke check failed:"); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); +} + +console.log("Mocked generation smoke check passed."); diff --git a/src/App.tsx b/src/App.tsx index 4f20bfc..91e878f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,10 +14,13 @@ import { ToolOutlined, WalletOutlined, } from "@ant-design/icons"; -import { lazy, Suspense, useCallback, useEffect, useMemo, useRef } from "react"; +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import ErrorBoundary from "./components/ErrorBoundary"; +import { reportError } from "./utils/errorReporting"; +import { initNotificationPermission } from "./utils/generationNotifier"; import PageTransition from "./components/PageTransition"; import ToastContainer from "./components/toast/ToastContainer"; +import { toast } from "./components/toast/toastStore"; import { aiGenerationClient } from "./api/aiGenerationClient"; import { keyServerClient } from "./api/keyServerClient"; import { notificationClient } from "./api/notificationClient"; @@ -25,12 +28,16 @@ import { SERVER_SESSION_REPLACED_EVENT, SERVER_SESSION_EXPIRED_EVENT, checkServerHealth, + clearAllUserStorage, getErrorMessage, type ServerSessionReplacedDetail, } from "./api/serverConnection"; import { webGenerationGateway, type CreatePreviewTaskInput } from "./api/webGenerationGateway"; import { translateTaskError } from "./utils/translateTaskError"; +import { recoverAndResumeTasks } from "./services/backgroundTaskRunner"; import AppShell from "./components/AppShell"; +const NotFoundPage = lazy(() => import("./components/NotFoundPage")); +const CompliancePage = lazy(() => import("./features/compliance/CompliancePage")); import { cloneWorkflow, createBlankWorkflow } from "./data/workflows"; const AgentPage = lazy(() => import("./features/agent/AgentPage")); const AssetsPage = lazy(() => import("./features/assets/AssetsPage")); @@ -41,9 +48,8 @@ const CommunityCaseAddPage = lazy(() => import("./features/community-review/Comm const CommunityReviewPage = lazy(() => import("./features/community-review/CommunityReviewPage")); const AvatarConsolePage = lazy(() => import("./features/digital-human/AvatarConsolePage")); const DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage")); +const DialogGeneratorPage = lazy(() => import("./features/dialog-generator/DialogGeneratorPage")); const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage")); -const EcommerceTemplatesPage = lazy(() => import("./features/ecommerce/EcommerceTemplatesPage")); -import type { TemplateCase } from "./features/ecommerce/ecommerceTemplates"; const HomePage = lazy(() => import("./features/home/HomePage")); const ImageWorkbenchPage = lazy(() => import("./features/image-workbench/ImageWorkbenchPage")); const MorePage = lazy(() => import("./features/more/MorePage")); @@ -54,9 +60,7 @@ const ResolutionUpscalePage = lazy(() => import("./features/resolution-upscale/R const WatermarkRemovalPage = lazy(() => import("./features/watermark-removal/WatermarkRemovalPage")); const SubtitleRemovalPage = lazy(() => import("./features/subtitle-removal/SubtitleRemovalPage")); const ScriptTokensPage = lazy(() => import("./features/script-tokens/ScriptTokensPage")); -const SizeTemplatePage = lazy(() => import("./features/size-template/SizeTemplatePage")); const TokenUsagePage = lazy(() => import("./features/script-tokens/TokenUsagePage")); -const SettingsPage = lazy(() => import("./features/settings/SettingsPage")); const WorkbenchPage = lazy(() => import("./features/workbench/WorkbenchPage")); import type { WorkbenchResultActionPayload } from "./features/workbench/WorkbenchPage"; import { @@ -101,43 +105,50 @@ const VIEW_KEYS = new Set([ "assets", "ecommerceHub", "ecommerce", - "ecommerceTemplates", "scriptTokens", "tokenUsage", - "settings", "imageWorkbench", "resolutionUpscale", "watermarkRemoval", "subtitleRemoval", + "dialogGenerator", "digitalHuman", "avatarConsole", "characterMix", "more", - "sizeTemplate", "communityReview", "communityCaseAdd", "report", "providerHealth", + "userAgreement", + "privacyPolicy", + "not-found", ]); -const PUBLIC_VIEW_SET = new Set(["home", "login", "community", "more", "ecommerceTemplates", "sizeTemplate"]); +const PUBLIC_VIEW_SET = new Set(["home", "login", "community", "more", "dialogGenerator", "userAgreement", "privacyPolicy", "not-found"]); function normalizeViewKey(rawView: string): WebViewKey { const normalized = rawView === "profile" || rawView === "auth" ? "login" - : rawView === "ecommerceHub" - ? "ecommerce" + : rawView === "ecommerceHub" + ? "ecommerce" + : rawView === "terms" || rawView === "agreement" || rawView === "user-agreement" + ? "userAgreement" + : rawView === "privacy" || rawView === "privacy-policy" + ? "privacyPolicy" : rawView === "community-review" ? "communityReview" : rawView === "community-case-add" ? "communityCaseAdd" : rawView; - return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "home"; + return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "not-found"; } 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 { @@ -149,7 +160,8 @@ function isWorkspaceView(view: WebViewKey): boolean { view !== "ecommerceHub" && view !== "ecommerce" && view !== "scriptTokens" && - view !== "login" + view !== "login" && + view !== "not-found" ); } @@ -277,6 +289,12 @@ function App() { const markAllNotificationsRead = useAppStore((s) => s.markAllNotificationsRead); const clearAppState = useAppStore((s) => s.clearAppState); + const [ecommerceEverMounted, setEcommerceEverMounted] = useState(false); + const isEcommerceActive = activeView === "ecommerce" || activeView === "ecommerceHub"; + useEffect(() => { + if (isEcommerceActive && !ecommerceEverMounted) setEcommerceEverMounted(true); + }, [isEcommerceActive]); // eslint-disable-line react-hooks/exhaustive-deps + // Dismiss boot splash after first render useEffect(() => { const splash = document.getElementById("app-boot-splash"); @@ -287,6 +305,25 @@ function App() { } }, []); + // Pre-warm notification permission (lazy, on first click) + useEffect(() => { initNotificationPermission(); }, []); + + // Global unhandled error / rejection listeners — report to server + useEffect(() => { + const handleUnhandled = (event: ErrorEvent) => { + reportError(event.error || new Error(event.message), "unhandled"); + }; + const handleRejection = (event: PromiseRejectionEvent) => { + reportError(event.reason instanceof Error ? event.reason : new Error(String(event.reason)), "rejection"); + }; + window.addEventListener("error", handleUnhandled); + window.addEventListener("unhandledrejection", handleRejection); + return () => { + window.removeEventListener("error", handleUnhandled); + window.removeEventListener("unhandledrejection", handleRejection); + }; + }, []); + // Initialize canvasWorkflow if null useEffect(() => { if (!canvasWorkflow) { @@ -302,6 +339,11 @@ function App() { } }, []); // eslint-disable-line react-hooks/exhaustive-deps + // ── Recover background tasks on app start ────────── + useEffect(() => { + recoverAndResumeTasks(); + }, []); + const navItems = useMemo( () => [ { key: "home", label: "首页", hint: "项目入口", icon: }, @@ -312,12 +354,6 @@ function App() { hint: "AI创作与海报生成", icon: , }, - { - key: "sizeTemplate", - label: "示例模板", - hint: "平台比例与导出尺寸", - icon: , - }, { key: "canvas", label: "画布", hint: "进入自由画布编排", icon: }, { key: "community", label: "社区", hint: "案例分享与导入", icon: }, { key: "scriptTokens", label: "剧本评分", hint: "剧本评分系统", icon: }, @@ -344,7 +380,7 @@ function App() { }, [setView, setWorkspaceExpanded]); const clearAuthenticatedState = useCallback((options?: { resetView?: boolean }) => { - keyServerClient.clearSession(); + clearAllUserStorage(); clearSessionState(); setProjects([]); setProjectsLoaded(true); @@ -357,12 +393,12 @@ function App() { canvasAutoOpenedRecentRef.current = false; setWorkspaceExpanded(false); if (options?.resetView) { - handleSetView("workbench"); + handleSetView("login"); } }, [clearSessionState, setProjects, setProjectsLoaded, setUsage, clearTasks, setRuntimeNotifications, setServerNotifications, setCanvasWorkflow, setCurrentCanvasProjectId, setWorkspaceExpanded, handleSetView]); const showSessionReplacedModal = useCallback((message?: string) => { - clearAuthenticatedState(); + clearAuthenticatedState({ resetView: true }); showSessionReplaced(message); }, [clearAuthenticatedState, showSessionReplaced]); @@ -380,11 +416,6 @@ function App() { }; }, [showSessionReplacedModal]); - const handleOpenEcommerceTemplate = useCallback((template: TemplateCase) => { - setPendingEcommerceTemplate(template); - handleSetView("ecommerce"); - }, [setPendingEcommerceTemplate, handleSetView]); - const hydrateAccountData = useCallback(async (nextSession: WebUserSession | null) => { setProjectsLoaded(false); if (!nextSession) { @@ -492,7 +523,7 @@ function App() { if (nextSession) { setSession(nextSession); } else { - clearAuthenticatedState(); + clearAuthenticatedState({ resetView: true }); } } finally { checking = false; @@ -681,11 +712,14 @@ function App() { } canvasAutoOpenedRecentRef.current = true; - void handleOpenProject(projects[0]); + handleOpenProject(projects[0]).catch(() => { + // Reset flag on failure so auto-open can retry on next dependency change + canvasAutoOpenedRecentRef.current = false; + }); }, [ activeView, - canvasWorkflow?.nodes.length, - canvasWorkflow?.source, + canvasWorkflow.nodes.length, + canvasWorkflow.source, currentCanvasProjectId, handleOpenProject, projects, @@ -827,6 +861,10 @@ function App() { setSession(nextSession); await hydrateAccountData(nextSession); + if (nextSession.user.email && !nextSession.user.emailVerified) { + toast.info("邮箱尚未验证,部分功能可能受限,请在登录页通过邮箱验证码完成验证"); + } + const action = pendingAction; closeLoginPrompt(); if (action) { @@ -987,25 +1025,6 @@ function App() { }, [activeView, session]); // eslint-disable-line react-hooks/exhaustive-deps const activePage = (() => { - if (!session && !PUBLIC_VIEWS.has(activeView)) { - return ( - handleSetView("workbench")} - onOpenCommunity={() => handleSetView("community")} - onDeleteProject={handleDeleteProject} - /> - ); - } switch (activeView) { case "login": return ( @@ -1049,7 +1068,7 @@ function App() { case "canvas": return ( ; case "ecommerce": case "ecommerceHub": - return ( - setPendingEcommerceTemplate(null)} - /> - ); - case "ecommerceTemplates": - return ( - handleSetView("more")} - onOpenEcommerce={() => handleSetView("ecommerce")} - onSelectTemplate={handleOpenEcommerceTemplate} - onStartCreate={handleStartTemplateCanvasCreate} - onOpenProject={handleOpenProject} - onDeleteProject={handleDeleteProject} - /> - ); + return null; case "digitalHuman": return ( ; - case "sizeTemplate": - return ( - handleSetView("more")} - onOpenEcommerce={() => handleSetView("ecommerce")} - onSelectView={handleSetView} - /> - ); case "scriptTokens": return ; case "tokenUsage": @@ -1141,8 +1126,6 @@ function App() { onSelectView={handleSetView} /> ); - case "settings": - return ; case "imageWorkbench": return ( ); + case "dialogGenerator": + return ; case "report": return ; case "providerHealth": return ; + case "userAgreement": + return ; + case "privacyPolicy": + return ; case "communityReview": return ( ); case "home": - default: return ( handleSetView("workbench")} @@ -1218,8 +1206,13 @@ function App() { onOpenEcommerce={() => handleSetView("ecommerce")} onOpenScriptReview={() => handleSetView("scriptTokens")} onOpenTokenMonitor={() => handleSetView("tokenUsage")} + onSelectView={handleSetView} + onOpenImageTool={handleOpenImageWorkbenchTool} /> ); + case "not-found": + default: + return handleSetView("home")} />; } })(); @@ -1238,7 +1231,7 @@ function App() { onMarkNotificationRead={handleMarkNotificationRead} onMarkAllNotificationsRead={handleMarkAllNotificationsRead} > - +
@@ -1251,6 +1244,26 @@ function App() { + {/* KeepAlive: EcommercePage stays mounted once visited, hidden via display:none */} + {ecommerceEverMounted && ( +
+ + setPendingEcommerceTemplate(null)} + /> + +
+ )} + {loginPromptOpen && pendingAction ? (
+ ); + } + + return ( +
+
+ 客户端错误 ({total}) +
+ + +
+
+
+ {errors.length === 0 ? ( +
暂无错误
+ ) : ( + errors.map((err) => ( +
+ + {err.source} + {err.message.slice(0, 120)} + {err.count} + + +
+
URL: {err.url}
+
User: {err.user_id || "匿名"}
+ {err.stack ?
{err.stack.slice(0, 1000)}
: null} +
+
+ )) + )} +
+ {maxPage > 1 ? ( +
+ + {page} / {maxPage} + +
+ ) : null} +
+ ); +} + +export default AdminMonitor; diff --git a/src/components/AnimatedPanel.tsx b/src/components/AnimatedPanel.tsx new file mode 100644 index 0000000..53e0df8 --- /dev/null +++ b/src/components/AnimatedPanel.tsx @@ -0,0 +1,54 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; + +interface AnimatedPanelProps { + open: boolean; + children: ReactNode; + className?: string; + /** Duration in ms for the exit animation before unmounting. */ + exitDuration?: number; +} + +export function AnimatedPanel({ open, children, className, exitDuration = 140 }: AnimatedPanelProps) { + const [mounted, setMounted] = useState(open); + const [visible, setVisible] = useState(open); + const timerRef = useRef(null); + + useEffect(() => { + if (open) { + if (timerRef.current) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + setMounted(true); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setVisible(true); + }); + }); + } else { + setVisible(false); + timerRef.current = window.setTimeout(() => { + setMounted(false); + timerRef.current = null; + }, exitDuration); + } + }, [open, exitDuration]); + + useEffect(() => { + return () => { + if (timerRef.current) { + window.clearTimeout(timerRef.current); + } + }; + }, []); + + if (!mounted) return null; + + return ( +
+ {children} +
+ ); +} \ No newline at end of file diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index b1bdc16..662c17f 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -3,6 +3,7 @@ import { ArrowUpOutlined, CheckCircleOutlined, FlagOutlined, + InfoCircleOutlined, LoginOutlined, LogoutOutlined, PlusCircleOutlined, @@ -11,11 +12,17 @@ import { } from "@ant-design/icons"; import { useEffect, useMemo, useRef, useState } from "react"; import type { ReactNode } from "react"; +import { publicConfigClient, type WebPublicConfig } from "../api/publicConfigClient"; +import { toast } from "./toast/toastStore"; 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"; import { RechargeModal } from "./RechargeModal/RechargeModal"; +import { AnimatedPanel } from "./AnimatedPanel"; +import AdminMonitor from "./AdminMonitor"; +import CookieConsentBanner from "./CookieConsentBanner"; interface AppShellProps { activeView: WebViewKey; @@ -33,7 +40,7 @@ interface AppShellProps { children: ReactNode; } -const BRAND_LOGO_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/logo.png"; +const BRAND_LOGO_URL = ossAssets.brand.logo; function formatBalance(cents: number): string { const value = Math.max(0, cents) / 100; @@ -60,10 +67,15 @@ function AppShell({ const submenuHideTimerRef = useRef(null); const [profileOpen, setProfileOpen] = useState(false); const [rechargeOpen, setRechargeOpen] = useState(false); + 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"; const isImmersiveView = activeView === "agent" || activeView === "avatarConsole"; - const showFloatingNav = !isAuthView && !isImmersiveView && activeView !== "home"; + const showFloatingNav = (!isAuthView || !!session) && !isImmersiveView && activeView !== "home"; const toolSurfaceViews = [ "workbench", "canvas", @@ -75,6 +87,7 @@ function AppShell({ "imageWorkbench", "resolutionUpscale", "digitalHuman", + "dialogGenerator", "avatarConsole", "characterMix", ] as WebViewKey[]; @@ -100,6 +113,15 @@ function AppShell({ [navItems], ); + useEffect(() => { + if (activeView !== prevActiveViewRef.current) { + setNavJustActivated(activeView); + prevActiveViewRef.current = activeView; + const timer = window.setTimeout(() => setNavJustActivated(null), 320); + return () => window.clearTimeout(timer); + } + }, [activeView]); + useEffect(() => { if (typeof document === "undefined") { return; @@ -115,6 +137,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; @@ -128,6 +166,17 @@ function AppShell({ return () => document.removeEventListener("pointerdown", handlePointerDown); }, [profileOpen]); + useEffect(() => { + if (!infoOpen) return; + const handleInfoOutside = (event: PointerEvent) => { + if (!infoRef.current?.contains(event.target as Node)) { + setInfoOpen(false); + } + }; + document.addEventListener("pointerdown", handleInfoOutside); + return () => document.removeEventListener("pointerdown", handleInfoOutside); + }, [infoOpen]); + useEffect(() => { if (!session) { setProfileOpen(false); @@ -188,7 +237,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); @@ -223,8 +271,8 @@ function AppShell({ + +
+
备案信息
+
{publicConfig.icpRecord || "由服务器配置"}
+
公司地址
+
{publicConfig.companyAddress || "由服务器配置"}
+
联系电话
+
{publicConfig.contactPhone || "由服务器配置"}
+
+ +
+
- {session && profileOpen ? ( -
+
{avatarUrl ? {displayName} : avatarLabel} @@ -352,7 +423,7 @@ function AppShell({
{usage.videoUsed}
- {import.meta.env.VITE_KEY_SERVER_URL || "使用预览数据"} + {session?.source === "server" ? "服务器会话" : "预览会话"} ) : null} -
- ) : null} +
@@ -419,7 +489,9 @@ function AppShell({
{children}
+ {session?.user.role === "admin" ? : null} setRechargeOpen(false)} currentBalance={displayedBalanceCents} /> +
); } diff --git a/src/components/CookieConsentBanner.tsx b/src/components/CookieConsentBanner.tsx new file mode 100644 index 0000000..ebdebb5 --- /dev/null +++ b/src/components/CookieConsentBanner.tsx @@ -0,0 +1,31 @@ +import { useEffect, useState } from "react"; + +const COOKIE_CONSENT_KEY = "omniai:cookie-consent:v1"; + +export default function CookieConsentBanner() { + const [visible, setVisible] = useState(false); + + useEffect(() => { + setVisible(localStorage.getItem(COOKIE_CONSENT_KEY) !== "accepted"); + }, []); + + const accept = () => { + localStorage.setItem(COOKIE_CONSENT_KEY, "accepted"); + setVisible(false); + }; + + if (!visible) return null; + + return ( +
+
+ Cookie 与本地存储提示 +

我们使用 Cookie 和本地存储保存登录状态、偏好设置、创作草稿和断点续传数据,用于保障服务正常运行。

+
+
+ 查看隐私政策 + +
+
+ ); +} diff --git a/src/components/NotFoundPage.tsx b/src/components/NotFoundPage.tsx new file mode 100644 index 0000000..2a09a9c --- /dev/null +++ b/src/components/NotFoundPage.tsx @@ -0,0 +1,24 @@ +import { HomeOutlined } from "@ant-design/icons"; +import { useCallback } from "react"; + +interface NotFoundPageProps { + onGoHome: () => void; +} + +function NotFoundPage({ onGoHome }: NotFoundPageProps) { + return ( +
+
+
404
+

页面未找到

+

您访问的页面不存在或已被移除。

+ +
+
+ ); +} + +export default NotFoundPage; diff --git a/src/components/NotificationCenter.tsx b/src/components/NotificationCenter.tsx index db9b9a0..586b9f2 100644 --- a/src/components/NotificationCenter.tsx +++ b/src/components/NotificationCenter.tsx @@ -10,6 +10,7 @@ import { } from "@ant-design/icons"; import { useEffect, useRef, useState } from "react"; import type { WebNotification, WebNotificationType, WebViewKey } from "../types"; +import { AnimatedPanel } from "./AnimatedPanel"; const NOTIFICATION_ICONS: Record = { task_completed: , @@ -115,8 +116,7 @@ function NotificationCenter({ items, onNavigate, onMarkRead, onMarkAllRead, onCl {unreadCount > 99 ? "99+" : unreadCount} )} - {open && ( -
+
通知中心
@@ -158,8 +158,7 @@ function NotificationCenter({ items, onNavigate, onMarkRead, onMarkAllRead, onCl )) )}
-
- )} +
); } diff --git a/src/components/PageTransition.tsx b/src/components/PageTransition.tsx index b008505..058a5d9 100644 --- a/src/components/PageTransition.tsx +++ b/src/components/PageTransition.tsx @@ -7,18 +7,70 @@ interface PageTransitionProps { const EXIT_DURATION_MS = 180; +const NAV_ORDER: string[] = [ + "home", + "workbench", + "ecommerce", + "ecommerceTemplates", + "sizeTemplate", + "canvas", + "scriptTokens", + "tokenUsage", + "community", + "assets", + "more", + "imageWorkbench", + "resolutionUpscale", + "watermarkRemoval", + "subtitleRemoval", + "dialogGenerator", + "digitalHuman", + "avatarConsole", + "characterMix", + "agent", + "login", + "profile", + "report", +]; + +function getNavIndex(key: string): number { + return NAV_ORDER.indexOf(key); +} + export default function PageTransition({ viewKey, children }: PageTransitionProps) { const [displayedChildren, setDisplayedChildren] = useState(children); const [phase, setPhase] = useState<"idle" | "exit">("idle"); + const [exitDirection, setExitDirection] = useState<"forward" | "backward" | "neutral">("neutral"); const prevKeyRef = useRef(viewKey); const timerRef = useRef>(); useEffect(() => { if (viewKey === prevKeyRef.current) { setDisplayedChildren(children); + // Cancel any active exit animation — children updated but viewKey stable. + setPhase("idle"); return; } + + const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (prefersReducedMotion) { + prevKeyRef.current = viewKey; + setDisplayedChildren(children); + setPhase("idle"); + return; + } + + const prevIndex = getNavIndex(prevKeyRef.current); + const nextIndex = getNavIndex(viewKey); + if (prevIndex < nextIndex) { + setExitDirection("forward"); + } else if (prevIndex > nextIndex) { + setExitDirection("backward"); + } else { + setExitDirection("neutral"); + } prevKeyRef.current = viewKey; + setPhase("exit"); timerRef.current = setTimeout(() => { setDisplayedChildren(children); @@ -27,8 +79,12 @@ export default function PageTransition({ viewKey, children }: PageTransitionProp return () => clearTimeout(timerRef.current); }, [viewKey, children]); + const dirClass = exitDirection === "forward" ? " is-forward" : exitDirection === "backward" ? " is-backward" : ""; + + if (!displayedChildren) return null; + return ( -
+
{displayedChildren}
); diff --git a/src/components/RechargeModal/RechargeModal.tsx b/src/components/RechargeModal/RechargeModal.tsx index 2254028..95be2eb 100644 --- a/src/components/RechargeModal/RechargeModal.tsx +++ b/src/components/RechargeModal/RechargeModal.tsx @@ -1,7 +1,10 @@ import { CheckCircleOutlined, CloseOutlined, CrownOutlined, RocketOutlined } from "@ant-design/icons"; import { useMemo, useState, type ReactNode } from "react"; +import { keyServerClient, type RechargeOrderResult } from "../../api/keyServerClient"; +import { toast } from "../toast/toastStore"; type RechargeAudience = "personal" | "enterprise"; +type PaymentMethod = "wechat" | "alipay" | "bank"; interface MembershipPlan { id: string; @@ -107,6 +110,12 @@ const rechargeRules = [ "退费规则:充值积分到账后不支持退换、折现,仅限平台内消费", ]; +const paymentMethods: Array<{ id: PaymentMethod; label: string; hint: string }> = [ + { id: "wechat", label: "微信支付", hint: "生成支付链接或二维码" }, + { id: "alipay", label: "支付宝", hint: "生成支付链接或二维码" }, + { id: "bank", label: "对公转账", hint: "企业客户可联系客服确认" }, +]; + interface RechargeModalProps { open: boolean; onClose: () => void; @@ -116,14 +125,43 @@ interface RechargeModalProps { export function RechargeModal({ open, onClose, currentBalance }: RechargeModalProps) { const [activeAudience, setActiveAudience] = useState("personal"); const [selectedPlanIds, setSelectedPlanIds] = useState>(defaultSelectedPlanIds); + const [paymentMethod, setPaymentMethod] = useState("wechat"); + const [submitting, setSubmitting] = useState(false); + const [order, setOrder] = useState(null); const visiblePlans = useMemo(() => membershipPlans.filter((plan) => plan.audience === activeAudience), [activeAudience]); const selectedPlanId = selectedPlanIds[activeAudience]; + const selectedPlan = membershipPlans.find((plan) => plan.id === selectedPlanId) ?? visiblePlans[0]; const handlePlanSelect = (plan: MembershipPlan) => { setSelectedPlanIds((current) => ({ ...current, [plan.audience]: plan.id, })); + setOrder(null); + }; + + const handleCreateOrder = async () => { + if (!selectedPlan || submitting) return; + + setSubmitting(true); + try { + const nextOrder = await keyServerClient.createRechargeOrder({ planId: selectedPlan.id, paymentMethod }); + setOrder(nextOrder); + if (nextOrder.payUrl) { + window.open(nextOrder.payUrl, "_blank", "noopener,noreferrer"); + } + toast.success("充值订单已创建"); + } catch (error) { + const message = error instanceof Error ? error.message : "订单创建失败,请联系客服处理。"; + toast.error(message); + setOrder({ + orderId: `support-${Date.now()}`, + status: "manual-review", + message: "支付接口暂不可用,请通过页面联系方式联系客服完成充值。", + }); + } finally { + setSubmitting(false); + } }; if (!open) return null; @@ -224,6 +262,44 @@ export function RechargeModal({ open, onClose, currentBalance }: RechargeModalPr ))} + +
+
+ 支付确认 +

{selectedPlan.name} · {selectedPlan.period}

+

{selectedPlan.price},{selectedPlan.grant}

+
+
+ {paymentMethods.map((method) => ( + + ))} +
+ + {order ? ( +
+ 订单号:{order.orderId} + 状态:{order.status} + {order.qrCodeUrl ? 支付二维码 : null} + {order.payUrl ? 打开支付链接 : null} +

{order.message || "支付完成后积分将自动入账,如长时间未到账请联系客服。"}

+
+ ) : null} +
); diff --git a/src/data/ossAssets.ts b/src/data/ossAssets.ts new file mode 100644 index 0000000..14136d8 --- /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: [oss("static/banners/light2_轮播1.jpg"), oss("static/banners/light2_轮播2.jpg"), oss("static/banners/light2_轮播3.jpg")], + 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/assets/AssetsPage.tsx b/src/features/assets/AssetsPage.tsx index 5c94c07..e3cf85c 100644 --- a/src/features/assets/AssetsPage.tsx +++ b/src/features/assets/AssetsPage.tsx @@ -100,14 +100,14 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) { setContextMenu({ x: e.clientX, y: e.clientY, asset }); }, []); - const handleDeleteAsset = useCallback(async () => { - if (!contextMenu) return; - const { asset } = contextMenu; + const handleDeleteAsset = useCallback(async (asset?: LibraryAssetItem) => { + const target = asset || contextMenu?.asset; + if (!target) return; setContextMenu(null); try { - await assetClient.delete(asset.id); - setServerAssets((prev) => prev.filter((a) => a.id !== asset.id)); - setServerNotice(`已删除 ${asset.name}`); + await assetClient.delete(target.id); + setServerAssets((prev) => prev.filter((a) => a.id !== target.id)); + setServerNotice(`已删除 ${target.name}`); } catch (err) { setServerNotice(err instanceof Error ? err.message : "删除失败"); } @@ -287,32 +287,42 @@ function AssetsPage({ isAuthenticated, onOpenLogin }: AssetsPageProps) { {visibleAssets.length ? (
{visibleAssets.map((asset) => ( - + + +
))} ) : isLoading ? ( diff --git a/src/features/canvas/CanvasPage.tsx b/src/features/canvas/CanvasPage.tsx index e57ad25..1d99981 100644 --- a/src/features/canvas/CanvasPage.tsx +++ b/src/features/canvas/CanvasPage.tsx @@ -26,7 +26,6 @@ VideoCameraOutlined, } from "@ant-design/icons"; import { - Background, ReactFlow, } from "@xyflow/react"; import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type MouseEvent, type WheelEvent } from "react"; @@ -48,6 +47,7 @@ import { } from "./canvasCommunityPublish"; import { createCanvasAssetRefFromGeneratedResult, persistCanvasGeneratedResultAsset } from "./canvasAssetPersistence"; import { normalizeCanvasWorkflowSchema } from "./canvasWorkflowSchema"; +import { createBlankWorkflow } from "../../data/workflows"; import { useCanvasHistory, type CanvasHistorySnapshot } from "./useCanvasHistory"; import { useCanvasKeyboard } from "./useCanvasKeyboard"; import { useCanvasNodeDrag } from "./useCanvasNodeDrag"; @@ -182,6 +182,7 @@ import { } from "./canvasWorkflowDeserialize"; import { CanvasNodeToolbar, CanvasNodeVideoPlayer, CanvasSelectChip } from "./canvasComponents"; import type { CanvasNodeToolbarAction } from "./canvasComponents"; +import { CanvasMultiGridPanel, CanvasUpscalePanel, CanvasInpaintPanel } from "./canvasToolPanels"; import { CanvasSmoothedProgressRing } from "./CanvasSmoothedProgressRing"; const canvasEnterpriseVideoModelOptions: CanvasOption[] = ENTERPRISE_VIDEO_MODEL_OPTIONS.map((option) => ({ @@ -310,7 +311,7 @@ function getCameraMotionPrompt(value: string): string { } function CanvasPage({ - workflow, + workflow: rawWorkflow, projectId, projects = [], projectsLoaded = true, @@ -323,6 +324,7 @@ function CanvasPage({ onSaveWorkflow, onCreateTask, }: CanvasPageProps) { + const workflow = rawWorkflow || createBlankWorkflow(); const [contextMenu, setContextMenu] = useState(null); const [nodeMenu, setNodeMenu] = useState(null); const [textNodeMenu, setTextNodeMenu] = useState<{ left: number; top: number; nodeId: string } | null>(null); @@ -335,6 +337,7 @@ function CanvasPage({ const [imageFocusNodeId, setImageFocusNodeId] = useState(null); const [imageFocusDraft, setImageFocusDraft] = useState(null); const [imageFocusDrag, setImageFocusDrag] = useState(null); + const [canvasToolModal, setCanvasToolModal] = useState<{ tool: "multiGrid" | "upscale" | "inpaint"; imageNode: CanvasImageNode } | null>(null); const [stylePickerImageNodeId, setStylePickerImageNodeId] = useState(null); const [stylePickerCases, setStylePickerCases] = useState([]); const [stylePickerLoading, setStylePickerLoading] = useState(false); @@ -404,6 +407,7 @@ function CanvasPage({ textGenerationState, imageGenerationState, videoGenerationState, generationToast, setGenerationToast, imageGenerationInFlightRef, textGenerationInFlightRef, textGenerationAbortControllersRef, + canvasGenKeepaliveRestoredRef, setTextGenerationStatus, setImageGenerationStatus, setVideoGenerationStatus, restoreKeepaliveTasks, resetGenerationState, } = useCanvasGeneration({ setImageNodes, setVideoNodes }); @@ -524,6 +528,7 @@ function CanvasPage({ const canvasAssets = serverAssets.filter((asset) => asset.imageUrl); const shouldShowEmptyProjectState = projectsLoaded && projects.length === 0 && !projectId && workflow.source === "blank" && workflow.nodes.length === 0; + const isWaitingForProjects = isAuthenticated && !projectsLoaded; const [projectSaveState, setProjectSaveState] = useState({ status: "idle", message: "", @@ -571,10 +576,13 @@ function CanvasPage({ imageNodeIdRef.current = nextImageNodes.length + 1; videoNodeIdRef.current = nextVideoNodes.length + 1; + // Reset keepalive flag so tasks can be restored for this project + canvasGenKeepaliveRestoredRef.current = false; if (projectId && isAuthenticated) { restoreKeepaliveTasks(projectId, nextImageNodes, nextVideoNodes); } - }, [workflow.id, workflow.nodes, projectId, isAuthenticated]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workflow.id, workflow.nodes, projectId]); useEffect(() => { if (!isAuthenticated) { @@ -2639,7 +2647,23 @@ function CanvasPage({ } : null; })() - : null; + : connectionDropMenu + ? (() => { + const source = getNodePortPoint(connectionDropMenu.sourcePort); + const target = getCanvasWorldPointFromClient(connectionDropMenu.originLeft, connectionDropMenu.originTop); + return source + ? { + id: "pending-link-preview", + sourceX: source.x, + sourceY: source.y, + targetX: target.x, + targetY: target.y, + sourceSide: connectionDropMenu.sourcePort.side, + targetSide: null, + } + : null; + })() + : null; const openCanvasAddNodeMenu = useCallback((clientX: number, clientY: number) => { const menuPosition = positionFloatingMenu(clientX, clientY, 260, 390, 0); @@ -2802,13 +2826,15 @@ function CanvasPage({ if (targetPort) { connectCanvasPorts(connectorDrag.port, targetPort); } else { - const menuPosition = positionFloatingMenu(event.clientX, event.clientY, 200, 160, 0); + const menuPosition = positionFloatingMenu(event.clientX, event.clientY, 200, 160, -40); setConnectionDropMenu({ ...menuPosition, originLeft: event.clientX, originTop: event.clientY, sourcePort: connectorDrag.port, }); + setPendingLinkPort(null); + setPendingLinkPreviewPoint(null); } } else { clearPendingConnector(); @@ -2833,7 +2859,7 @@ function CanvasPage({ }, [selectedNode]); const handleCanvasMouseMove = (event: MouseEvent) => { - if (!pendingLinkPort) return; + if (!pendingLinkPort || connectionDropMenu) return; setPendingLinkPreviewPoint(getCanvasWorldPointFromClient(event.clientX, event.clientY)); }; @@ -3524,18 +3550,19 @@ function CanvasPage({ return ( -
+
event.preventDefault() : handleCanvasContextMenu} - onMouseDownCapture={shouldShowEmptyProjectState ? undefined : handleCanvasMouseDown} - onDoubleClick={shouldShowEmptyProjectState ? undefined : handleCanvasDoubleClick} - onMouseMove={shouldShowEmptyProjectState ? undefined : handleCanvasMouseMove} - onWheel={shouldShowEmptyProjectState ? undefined : handleCanvasWheel} + onAuxClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasAuxClick} + onContextMenu={(shouldShowEmptyProjectState || isWaitingForProjects) ? (event) => event.preventDefault() : handleCanvasContextMenu} + onMouseDownCapture={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasMouseDown} + onDoubleClick={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasDoubleClick} + onMouseMove={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasMouseMove} + onWheel={(shouldShowEmptyProjectState || isWaitingForProjects) ? undefined : handleCanvasWheel} style={{ - "--canvas-bg-size": `${24 * canvasViewport.zoom}px`, + "--canvas-bg-size": `${34 * canvasViewport.zoom}px`, + "--canvas-bg-dot": `${1.35 * canvasViewport.zoom}px`, "--canvas-bg-x": `${canvasViewport.x}px`, "--canvas-bg-y": `${canvasViewport.y}px`, cursor: canvasPanDrag ? "grabbing" : spacePanning ? "grab" : undefined, @@ -3555,7 +3582,7 @@ function CanvasPage({ className="studio-canvas-hidden-input" onChange={(event) => handleImageFileSelected(event, pendingImagePosition)} /> - {!shouldShowEmptyProjectState ? ( + {(!shouldShowEmptyProjectState || isWaitingForProjects) ? (
event.stopPropagation()}>
{projectNameEditing ? ( @@ -3650,7 +3677,7 @@ function CanvasPage({
) : null} - {!shouldShowEmptyProjectState && recentProjectsOpen ? ( + {(!shouldShowEmptyProjectState || isWaitingForProjects) && recentProjectsOpen ? (
+ {canvasToolModal && ( +
setCanvasToolModal(null)}> +
e.stopPropagation()} role="dialog" aria-modal="true" aria-label={canvasToolModal.tool === "multiGrid" ? "多宫格" : canvasToolModal.tool === "upscale" ? "超分" : "局部重绘"}> +
+

{canvasToolModal.tool === "multiGrid" ? "多宫格生成" : canvasToolModal.tool === "upscale" ? "图片超分" : "局部重绘"}

+ +
+
+ {canvasToolModal.tool === "multiGrid" && ( + { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} /> + )} + {canvasToolModal.tool === "upscale" && ( + { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} /> + )} + {canvasToolModal.tool === "inpaint" && ( + { setImageNodes((nodes) => nodes.map((n) => n.id === canvasToolModal.imageNode.id ? { ...n, imageUrl: url } : n)); setCanvasToolModal(null); }} /> + )} +
+
+
+ )} ); } diff --git a/src/features/canvas/canvasToolPanels.tsx b/src/features/canvas/canvasToolPanels.tsx new file mode 100644 index 0000000..02afbf7 --- /dev/null +++ b/src/features/canvas/canvasToolPanels.tsx @@ -0,0 +1,221 @@ +import { useCallback, useRef, useState } from "react"; +import { aiGenerationClient } from "../../api/aiGenerationClient"; +import { waitForTask } from "../../api/taskSubscription"; +import { toast } from "../../components/toast/toastStore"; +import type { CanvasImageNode } from "./canvasTypes"; + +interface CanvasToolPanelProps { + imageUrl: string; + imageNode: CanvasImageNode; + onComplete: (resultUrl: string) => void; +} + +export function CanvasMultiGridPanel({ imageUrl, onComplete }: CanvasToolPanelProps) { + const [gridMode, setGridMode] = useState<"grid-4" | "grid-9">("grid-4"); + const [prompt, setPrompt] = useState(""); + const [loading, setLoading] = useState(false); + const cancelRef = useRef(false); + + const handleGenerate = useCallback(async () => { + if (!imageUrl) return; + setLoading(true); + cancelRef.current = false; + try { + const { taskId } = await aiGenerationClient.createImageTask({ + model: "gpt-image-2", + prompt: prompt || "基于参考图生成多宫格变体", + referenceUrls: [imageUrl], + gridMode, + }); + const resultUrl = await waitForTask(taskId, { kind: "image", abortRef: cancelRef }); + if (resultUrl) { + onComplete(resultUrl); + toast.success("多宫格生成完成"); + } + } catch (err: unknown) { + if (!cancelRef.current) toast.error(err instanceof Error ? err.message : "多宫格生成失败"); + } finally { + setLoading(false); + } + }, [imageUrl, prompt, gridMode, onComplete]); + + return ( +
+
+
+ +
+ {([["grid-4", "2×2"], ["grid-9", "3×3"]] as const).map(([value, label]) => ( + + ))} +
+ +