diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..ff44e1f --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,39 @@ +# Gitea Actions CI —— 防回潮检查。 +# +# 注意:本文件需 Gitea 服务端【启用 Actions】并【配置 act_runner】后才会执行。 +# 未配置 runner 时本文件无副作用(不影响本地开发与 husky 钩子)。 +# 启用方式:Gitea 站点管理 → 启用 Actions;在 runner 主机注册 act_runner 并打 label。 +# +# 本地已有 husky 钩子兜底:pre-commit 跑 tsc+eslint(增量),pre-push 跑 css:audit。 +name: CI + +on: + push: + branches: [main, "main-merge-work"] + pull_request: + branches: [main] + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npm run type-check + + - name: Tests + run: npm run test:run + + - name: Lint (error 阻断,warning 放行) + run: npm run lint diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..aa72f92 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,75 @@ +// ESLint flat config(ESLint 9)。防回潮基建:锁定去重/抽取/合规成果,约束新代码。 +// 策略:warn 基线——历史问题(如 exhaustive-deps)设 warn 不阻断提交, +// 新代码的 error 类问题(unused vars 等)强制清零。CI/pre-commit 只拦 error。 +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import unusedImports from "eslint-plugin-unused-imports"; +import globals from "globals"; + +export default tseslint.config( + { + // 忽略构建产物、依赖、配置脚本、JS shim。 + ignores: [ + "dist/**", + "node_modules/**", + "coverage/**", + "**/*.config.{js,ts,mjs,cjs}", + "scripts/**", + "src/data/ossAssets.js", + ], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + globals: { + ...globals.browser, + ...globals.es2022, + }, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + "unused-imports": unusedImports, + }, + rules: { + ...reactHooks.configs.recommended.rules, + // 历史问题:warn 不阻断,渐进清理。 + "react-hooks/exhaustive-deps": "warn", + "@typescript-eslint/no-explicit-any": "warn", + // 未使用 import:error 且可 autofix 自动删除(unused-imports 插件专长)。 + "@typescript-eslint/no-unused-vars": "off", + "unused-imports/no-unused-imports": "error", + // 未使用局部变量:warn 基线(不自动删,避免误删 dead code 有副作用的赋值)。 + "unused-imports/no-unused-vars": [ + "warn", + { vars: "all", varsIgnorePattern: "^_", args: "after-used", argsIgnorePattern: "^_" }, + ], + // 允许 warn/error(现有 console.warn/error 是有意的诊断输出)。 + "no-console": ["warn", { allow: ["warn", "error"] }], + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + }, + }, + { + // 测试文件:补 vitest 全局,避免 describe/it/expect 误报未定义。 + files: ["**/*.{test,spec}.{ts,tsx}"], + languageOptions: { + globals: { + ...globals.node, + describe: "readonly", + it: "readonly", + expect: "readonly", + vi: "readonly", + beforeEach: "readonly", + afterEach: "readonly", + beforeAll: "readonly", + afterAll: "readonly", + }, + }, + }, +); diff --git a/lint-staged.config.mjs b/lint-staged.config.mjs index aa0ab61..7732ce6 100644 --- a/lint-staged.config.mjs +++ b/lint-staged.config.mjs @@ -1,10 +1,7 @@ -// lint-staged 配置 —— 配合 husky pre-commit 使用 -// -// 当前只运行 tsc 全量类型检查(tsc 不接受单文件增量检查), -// 未来可扩展 ESLint / Prettier / stylelint 等按文件的检查。 -// -// 函数语法返回原始命令字符串,lint-staged 不会追加文件名。 - +// lint-staged:pre-commit 时对暂存文件运行检查。 +// - tsc --noEmit:全量类型检查(函数语法返回命令,不追加文件名)。 +// - eslint --fix:仅对暂存的改动文件增量检查(新代码强制 error=0, +// warning 不阻断提交)。存量历史问题不会因此被卡住。 export default { - "*.{ts,tsx}": () => "tsc --noEmit", + "*.{ts,tsx}": [() => "tsc --noEmit", "eslint --fix"], }; diff --git a/package-lock.json b/package-lock.json index 134008f..8f630f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,13 +16,20 @@ "zustand": "5.0.13" }, "devDependencies": { + "@eslint/js": "^9.18.0", "@types/react": "18.2.55", "@types/react-dom": "18.2.18", "@vitejs/plugin-react": "4.2.1", "@vitest/coverage-v8": "^1.6.0", + "eslint": "^9.18.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.18", + "eslint-plugin-unused-imports": "^4.1.4", + "globals": "^15.14.0", "husky": "^9.1.7", "lint-staged": "^17.0.7", "typescript": "5.3.3", + "typescript-eslint": "^8.20.0", "vite": "5.1.0", "vite-plugin-compression2": "2.5.3", "vitest": "^1.6.0" @@ -120,6 +127,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -778,6 +786,226 @@ "node": ">=12" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", + "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.10.0.tgz", + "integrity": "sha512-gFHJ+xBOo4G3WRlR1e/3G8A6/KZAH6zcE/hkLRCZTi/B9avAG365QhFA8uOGzTMqgTghpn7/fSnscW++dpMSAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.18.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.18.0.tgz", + "integrity": "sha512-fK6L7rxcq6/z+AaQMtiFTkvbHkBLNlwyRxHpKawP0x3u9+NC6MQTnFW+AdpwC6gfHTW0051cokQgtTN2FqlxQA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz", + "integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.13.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", + "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@istanbuljs/schema": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", @@ -851,6 +1079,44 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@phosphor-icons/react": { "version": "2.1.10", "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz", @@ -1296,6 +1562,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -1309,6 +1582,7 @@ "integrity": "sha512-Y2Tz5P4yz23brwm2d7jNon39qoAtMMmalOQv6+fEFt1mT+FcM3D841wDpoUvFXhaYenuROCy3FZYqdTjM7qVyA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -1332,6 +1606,225 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.20.0.tgz", + "integrity": "sha512-naduuphVw5StFfqp4Gq4WhIBE2gN1GEmMUExpJYknZJdRnc+2gDzB8Z3+5+/Kv33hPQRDGzQO/0opHE72lZZ6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.20.0", + "@typescript-eslint/type-utils": "8.20.0", + "@typescript-eslint/utils": "8.20.0", + "@typescript-eslint/visitor-keys": "8.20.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.20.0.tgz", + "integrity": "sha512-gKXG7A5HMyjDIedBi6bUrDcun8GIjnI8qOwVLiY3rx6T/sHP/19XLJOnIq/FgQvWLHja5JN/LSE7eklNBr612g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.20.0", + "@typescript-eslint/types": "8.20.0", + "@typescript-eslint/typescript-estree": "8.20.0", + "@typescript-eslint/visitor-keys": "8.20.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.20.0.tgz", + "integrity": "sha512-J7+VkpeGzhOt3FeG1+SzhiMj9NzGD/M6KoGn9f4dbz3YzK9hvbhVTmLj/HiTp9DazIzJ8B4XcM80LrR9Dm1rJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.20.0", + "@typescript-eslint/visitor-keys": "8.20.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.20.0.tgz", + "integrity": "sha512-bPC+j71GGvA7rVNAHAtOjbVXbLN5PkwqMvy1cwGeaxUoRQXVuKCebRoLzm+IPW/NtFFpstn1ummSIasD5t60GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.20.0", + "@typescript-eslint/utils": "8.20.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.20.0.tgz", + "integrity": "sha512-cqaMiY72CkP+2xZRrFt3ExRBu0WmVitN/rYPZErA80mHjHx/Svgp8yfbzkJmDoQ/whcytOPO9/IZXnOc+wigRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.20.0.tgz", + "integrity": "sha512-Y7ncuy78bJqHI35NwzWol8E0X7XkRVS4K4P4TCyzWkOJih5NDvtoRDW4Ba9YJJoB2igm9yXDdYI/+fkiiAxPzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.20.0", + "@typescript-eslint/visitor-keys": "8.20.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.20.0.tgz", + "integrity": "sha512-dq70RUw6UK9ei7vxc4KQtBRk7qkHZv447OUZ6RPQMQl71I3NZxQJX/f32Smr+iqWrB02pHKn2yAdHBb0KNrRMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.20.0", + "@typescript-eslint/types": "8.20.0", + "@typescript-eslint/typescript-estree": "8.20.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.20.0.tgz", + "integrity": "sha512-v/BpkeeYAsPkKCkR8BDwcno0llhzWVqPOamQrAEMdpZav2Y9OVjd9dwJyBLJWwf335B5DmlifECIkZRJCaGaHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.20.0", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.2.1.tgz", @@ -1470,6 +1963,7 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1477,6 +1971,16 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/acorn-walk": { "version": "8.3.5", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", @@ -1490,6 +1994,23 @@ "node": ">=0.4.0" } }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -1532,6 +2053,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", @@ -1573,6 +2101,19 @@ "concat-map": "0.0.1" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -1593,6 +2134,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -1617,6 +2159,16 @@ "node": ">=8" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001799", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", @@ -1657,6 +2209,39 @@ "node": ">=4" } }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/check-error": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", @@ -1709,6 +2294,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1783,6 +2388,13 @@ "node": ">=6" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -1869,6 +2481,203 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.18.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.18.0.tgz", + "integrity": "sha512-+waTfRWQlSbpt3KWE+CjrPPYnbq9kfZIYUqapc0uBXyjTp8aYXZDsUH16m39Ryq3NjAVP4tjuF7KaukeqoCoaA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.19.0", + "@eslint/core": "^0.10.0", + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "9.18.0", + "@eslint/plugin-kit": "^0.2.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.1", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.1.0.tgz", + "integrity": "sha512-mpJRtPgHN2tNAvZ35AMfqeB3Xqeo273QxrHJsbBEPWODRM4r0yB6jfoROqKEYrOn27UtRPpcpHc2UqyBSuUNTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.18", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.18.tgz", + "integrity": "sha512-IRGEoFn3OKalm3hjfolEWGqoF/jPqeEYFp+C8B0WMzwGwBMvlRDQd06kghDhF0C61uJ6WfSDhEZE/sAQjduKgw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-plugin-unused-imports": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.1.4.tgz", + "integrity": "sha512-YptD6IzQjDardkl0POxnnRBhU1OEePMV0nd6siHaRBbd+lyh6NAhFEobiznKU7kTsSsDeSD62Pe7kAM1b7dAZQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", + "eslint": "^9.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", @@ -1876,6 +2685,16 @@ "dev": true, "license": "MIT" }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -1907,6 +2726,131 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -1997,6 +2941,39 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "15.14.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.14.0.tgz", + "integrity": "sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2040,6 +3017,43 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -2059,6 +3073,16 @@ "dev": true, "license": "ISC" }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", @@ -2075,6 +3099,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-stream": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", @@ -2155,6 +3202,29 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2168,6 +3238,27 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -2181,6 +3272,30 @@ "node": ">=6" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lint-staged": { "version": "17.0.7", "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.7.tgz", @@ -2240,6 +3355,29 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/log-update": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", @@ -2416,6 +3554,43 @@ "dev": true, "license": "MIT" }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mimic-fn": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", @@ -2501,6 +3676,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.47", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", @@ -2566,6 +3748,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/p-limit": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", @@ -2582,6 +3782,74 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate/node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -2687,6 +3955,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -2702,6 +3980,37 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/rc-util": { "version": "5.44.4", "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz", @@ -2721,6 +4030,7 @@ "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" }, @@ -2733,6 +4043,7 @@ "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.0" @@ -2757,6 +4068,16 @@ "node": ">=0.10.0" } }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -2790,6 +4111,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", @@ -2803,6 +4135,7 @@ "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.9" }, @@ -2842,6 +4175,30 @@ "fsevents": "~2.3.2" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/scheduler": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", @@ -3014,6 +4371,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-literal": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", @@ -3106,6 +4476,45 @@ "node": ">=14.0.0" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-detect": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", @@ -3130,6 +4539,29 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.20.0.tgz", + "integrity": "sha512-Kxz2QRFsgbWj6Xcftlw3Dd154b3cEPFqQC+qMZrMypSijPd4UanKKvoKDrJ4o8AIfZFKAF+7sMaEIR8mTElozA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.20.0", + "@typescript-eslint/parser": "8.20.0", + "@typescript-eslint/utils": "8.20.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, "node_modules/ufo": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", @@ -3168,12 +4600,23 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/vite": { "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.19.3", "postcss": "^8.4.35", @@ -3264,6 +4707,7 @@ "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "1.6.1", "@vitest/runner": "1.6.1", @@ -3357,6 +4801,16 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", diff --git a/package.json b/package.json index d6627cf..d57f829 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,9 @@ "build": "vite build", "preview": "vite preview --host 127.0.0.1", "type-check": "tsc -p tsconfig.json --noEmit", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "lint:strict": "eslint . --max-warnings=0", "test": "vitest", "test:run": "vitest run", "test:coverage": "vitest run --coverage", @@ -23,13 +26,20 @@ "zustand": "5.0.13" }, "devDependencies": { + "@eslint/js": "^9.18.0", "@types/react": "18.2.55", "@types/react-dom": "18.2.18", "@vitejs/plugin-react": "4.2.1", "@vitest/coverage-v8": "^1.6.0", + "eslint": "^9.18.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.18", + "eslint-plugin-unused-imports": "^4.1.4", + "globals": "^15.14.0", "husky": "^9.1.7", "lint-staged": "^17.0.7", "typescript": "5.3.3", + "typescript-eslint": "^8.20.0", "vite": "5.1.0", "vite-plugin-compression2": "2.5.3", "vitest": "^1.6.0" diff --git a/scripts/css-audit.mjs b/scripts/css-audit.mjs index 20ff14c..cfdc2ea 100644 --- a/scripts/css-audit.mjs +++ b/scripts/css-audit.mjs @@ -69,15 +69,40 @@ console.log( ); console.log(""); -// Exit non-zero if total !important exceeds a budget threshold. -// Current baseline: ~7795. Set budget slightly above to allow incremental work -// while preventing uncontrolled growth. -const IMPORTANT_BUDGET = 7820; -if (totals.important > IMPORTANT_BUDGET) { - console.error( - `FAIL: !important count ${totals.important} exceeds budget ${IMPORTANT_BUDGET}. ` + - `Run with --no-important-check to bypass (not recommended).`, - ); +// Per-file !important budgets for the worst offenders. +// These cap individual files so a single sheet cannot balloon unchecked. +// Current baselines (2026-06): ecommerce-standalone.css=10189, standalone/base.css=4958, +// standalone/overrides.css=1886. Budgets set ~1% above baseline to allow incremental +// work while preventing uncontrolled growth. Lower these as CSS gets cleaned up. +const PER_FILE_BUDGETS = { + "ecommerce-standalone.css": 10300, + "standalone/base.css": 5000, + "standalone/overrides.css": 1900, +}; + +let perFileFailed = false; +for (const r of REPORT) { + const budget = PER_FILE_BUDGETS[r.file]; + if (budget === undefined) continue; + if (r.important > budget) { + console.error( + `FAIL: ${r.file} !important count ${r.important} exceeds per-file budget ${budget}.`, + ); + perFileFailed = true; + } +} + +// Total !important budget across all stylesheets. +// Current baseline: ~18218. Set ~1% above to allow incremental work while +// preventing uncontrolled growth. Lower as CSS gets cleaned up. +const IMPORTANT_BUDGET = 18400; +if (perFileFailed || totals.important > IMPORTANT_BUDGET) { + if (totals.important > IMPORTANT_BUDGET) { + console.error( + `FAIL: !important count ${totals.important} exceeds budget ${IMPORTANT_BUDGET}. ` + + `Run with --no-important-check to bypass (not recommended).`, + ); + } process.exit(1); } else { console.log( diff --git a/src/App.tsx b/src/App.tsx index 2bf0a6f..c79923d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react"; +import { lazy, Suspense, useCallback, useEffect, useState } from "react"; import { BugOutlined, CheckCircleFilled, @@ -19,6 +19,8 @@ import ToastContainer from "./components/toast/ToastContainer"; import { toast } from "./components/toast/toastStore"; import { flushPendingGenerationRecords } from "./api/generationRecordClient"; import { keyServerClient } from "./api/keyServerClient"; +import { preloadPublicConfig, getLogoUrl } from "./api/publicConfigClient"; +import { preloadPlatformRules } from "./api/platformRulesClient"; import { setUserMaxConcurrency } from "./api/generationConcurrency"; import { SERVER_SESSION_EXPIRED_EVENT, @@ -155,6 +157,9 @@ function App() { const [sessionNotice, setSessionNotice] = useState(null); const [profileMenuOpen, setProfileMenuOpen] = useState(false); const [currentPage, setCurrentPage] = useState<"workspace" | "profile">("workspace"); + // 平台规则 gating:数据就绪(或兜底超时)后才渲染 EcommercePage, + // 保证其模块求值时 platformRulesClient 缓存已填充,拿到 API 数据。 + const [platformRulesReady, setPlatformRulesReady] = useState(false); const [workspaceChrome, setWorkspaceChrome] = useState({ isToolPage: false, }); @@ -183,6 +188,20 @@ function App() { initNotificationPermission(); }, []); + // 启动 gating:预加载平台规则。preload 自带超时+fallback 一定会 resolve; + // 另加 3s 兜底,避免极端情况下首屏久等(兜底放行后用 fallback,数据=正确值)。 + useEffect(() => { + let settled = false; + const markReady = () => { + if (settled) return; + settled = true; + setPlatformRulesReady(true); + }; + void preloadPlatformRules().then(markReady, markReady); + const fallbackTimer = window.setTimeout(markReady, 3_000); + return () => window.clearTimeout(fallbackTimer); + }, []); + useEffect(() => { if (!session) return; void flushPendingGenerationRecords(); @@ -242,6 +261,8 @@ function App() { let cancelled = false; const loadSession = async () => { + // 预加载公网配置(OSS base / logo URL),与 session 加载并行,不阻断启动。 + void preloadPublicConfig(); try { const nextSession = await keyServerClient.getCurrentSession(); if (cancelled) return; @@ -378,19 +399,26 @@ function App() { } > - undefined} - onOpenProject={() => undefined} - onDeleteProject={() => undefined} - onImportWorkflow={() => undefined} - onCreateTask={() => undefined} - onRequireLogin={() => openAuth("login")} - initialTemplate={null} - onInitialTemplateConsumed={() => undefined} - /> + {platformRulesReady ? ( + undefined} + onOpenProject={() => undefined} + onDeleteProject={() => undefined} + onImportWorkflow={() => undefined} + onCreateTask={() => undefined} + onRequireLogin={() => openAuth("login")} + initialTemplate={null} + onInitialTemplateConsumed={() => undefined} + /> + ) : ( +
+
+ 加载中... +
+ )}
@@ -414,7 +442,7 @@ function App() {

{authMode === "login" ? "欢迎回来" : "创建账号"}

{authMode === "login" ? "登录后继续你的 AI 创作之旅" : "注册即可免费体验全部功能"}

diff --git a/src/api/adVideoPlanClient.ts b/src/api/adVideoPlanClient.ts index 3c5a608..69eb2fc 100644 --- a/src/api/adVideoPlanClient.ts +++ b/src/api/adVideoPlanClient.ts @@ -108,7 +108,7 @@ export interface ComplianceCheck { } function findJsonSlice(raw: string): string { - const start = raw.search(/[\[{]/); + const start = raw.search(/[{[]/); if (start < 0) return raw; const stack: string[] = []; diff --git a/src/api/aiGenerationClient.ts b/src/api/aiGenerationClient.ts index bfdc128..47aaeae 100644 --- a/src/api/aiGenerationClient.ts +++ b/src/api/aiGenerationClient.ts @@ -44,6 +44,7 @@ export interface ImageProviderDebug { export interface ImageTaskCreateResponse { taskId: string; + resultUrl?: string | null; providerDebug?: ImageProviderDebug; } @@ -97,6 +98,7 @@ export interface ImageEditInput { prompt?: string; maskUrl?: string; ratio?: string; + referenceUrls?: string[]; n?: number; } @@ -126,7 +128,7 @@ export type ChatMessageContent = | Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }>; export interface ChatInput { - model: string; + model?: string; messages: Array<{ role: string; content: ChatMessageContent }>; stream?: boolean; temperature?: number; diff --git a/src/api/dtoParsers.test.ts b/src/api/dtoParsers.test.ts index 6d406eb..662ec9b 100644 --- a/src/api/dtoParsers.test.ts +++ b/src/api/dtoParsers.test.ts @@ -110,6 +110,15 @@ describe("parseImageTaskCreateResponse", () => { expect(result.providerDebug).toBeUndefined(); }); + it("extracts immediate image result URLs", () => { + const result = parseImageTaskCreateResponse({ + taskId: "img-sync", + result_url: "https://example.com/result.png", + }); + expect(result.taskId).toBe("img-sync"); + expect(result.resultUrl).toBe("https://example.com/result.png"); + }); + it("tolerates snake_case providerDebug fields", () => { const result = parseImageTaskCreateResponse({ taskId: "img-3", diff --git a/src/api/dtoParsers.ts b/src/api/dtoParsers.ts index 66d3a03..ae2ff48 100644 --- a/src/api/dtoParsers.ts +++ b/src/api/dtoParsers.ts @@ -130,8 +130,13 @@ export function parseTaskCreateResponse(payload: unknown): { taskId: string } { export function parseImageTaskCreateResponse(payload: unknown): ImageTaskCreateResponse { const base = parseTaskCreateResponse(payload); const body = isRecord(payload) ? payload : {}; + const resultUrl = toNullableString(body.resultUrl ?? body.result_url); const providerDebug = normalizeProviderDebug(body.providerDebug ?? body.provider_debug); - return providerDebug ? { ...base, providerDebug } : base; + return { + ...base, + resultUrl, + ...(providerDebug ? { providerDebug } : {}), + }; } /** diff --git a/src/api/generationRecordClient.ts b/src/api/generationRecordClient.ts index 9e376d9..52060a0 100644 --- a/src/api/generationRecordClient.ts +++ b/src/api/generationRecordClient.ts @@ -38,6 +38,39 @@ export interface SaveGenerationRecordResult { id: string; } +export interface GenerationRecord { + id: string; + clientRecordId: string; + tool: string; + mode?: string; + title: string; + status: GenerationRecordStatus; + prompt?: string; + taskIds: string[]; + assets: GenerationRecordAsset[]; + config: Record; + result: Record; + metadata: Record; + createdAt: string; + updatedAt: string; +} + +export interface ListGenerationRecordsParams { + tool?: string; + mode?: string; + status?: GenerationRecordStatus; + q?: string; + limit?: number; + offset?: number; +} + +export interface ListGenerationRecordsResult { + items: GenerationRecord[]; + total: number; + limit: number; + offset: number; +} + // 同一 clientRecordId 的保存去重:套图主流程、backgroundTaskRunner、useGenerationTasks // 三处都可能对同一条终态任务调用 saveGenerationRecord,SSE 重复推送 completed 时 // 单个 poller 内也会重复触发。这里做客户端幂等:in-flight 合流 + 成功后短期拦截, @@ -185,6 +218,23 @@ export async function flushPendingGenerationRecords(): Promise<{ synced: number; return { synced, remaining: remaining.length }; } +export async function listGenerationRecords(params: ListGenerationRecordsParams = {}): Promise { + const search = new URLSearchParams(); + if (params.tool) search.set("tool", params.tool); + if (params.mode) search.set("mode", params.mode); + if (params.status) search.set("status", params.status); + if (params.q) search.set("q", params.q); + if (params.limit !== undefined) search.set("limit", String(params.limit)); + if (params.offset !== undefined) search.set("offset", String(params.offset)); + + const suffix = search.toString(); + return serverRequest(`ai/generation-records${suffix ? `?${suffix}` : ""}`, { + method: "GET", + maxRetries: 1, + fallbackMessage: "Failed to load generation records", + }); +} + export async function deleteGenerationRecordByClientId(clientRecordId: string): Promise { await serverRequest<{ success: boolean }>(`ai/generation-records/by-client-id/${encodeURIComponent(clientRecordId)}`, { method: "DELETE", diff --git a/src/api/platformRulesClient.ts b/src/api/platformRulesClient.ts new file mode 100644 index 0000000..2270bc9 --- /dev/null +++ b/src/api/platformRulesClient.ts @@ -0,0 +1,470 @@ +// 电商平台规格 + 市场语言业务数据客户端(AGENTS.md 规则4合规)。 +// 数据由后端 GET /api/public/config/profile?name=web-ecommerce-platform-rules 下发, +// 不硬编码在前端业务逻辑里。 +// +// 时序设计(启动 gating):App 启动 boot splash 期间 await preloadPlatformRules(), +// 数据就绪后才渲染 EcommercePage(React.lazy)。因此 platformRules.ts 模块求值时 +// (随 EcommercePage chunk 加载)缓存已填充,其顶层派生常量拿到的是 API 数据。 +// +// FALLBACK = 完整当前生产数据:API 超时/失败时仍能正常工作(fallback 即正确值)。 +import type { EcommercePlatformSpec } from "../features/ecommerce/utils/platformRules"; +import { serverRequest } from "./serverConnection"; + +export interface MarketLanguageOption { + country: string; + languages: string[]; +} + +export interface PlatformRulesData { + platformSpecOptions: EcommercePlatformSpec[]; + marketLanguageOptions: MarketLanguageOption[]; + languageAliases: Record; + legacyPlatformAliases: Record; + domesticPlatformLabels: string[]; + domesticPlatformLanguages: string[]; + defaultEcommercePlatform: string; +} + +// ── FALLBACK:完整当前数据,逐字迁移自原 platformRules.ts ────────────── +const FALLBACK_PLATFORM_RULES: PlatformRulesData = { + platformSpecOptions: [ + { + label: "淘宝/天猫", + ratios: ["淘宝主图 / SKU 图 800×800px", "详情页宽 750px", "详情页宽 790px"], + defaultRatio: "淘宝主图 / SKU 图 800×800px", + ratioGroups: { + set: { + ratios: ["1000×1000px\u00a0\u00a0\u00a01:1", "800×800px\u00a0\u00a0\u00a01:1"], + defaultRatio: "1000×1000px\u00a0\u00a0\u00a01:1", + }, + detail: { + ratios: [ + "750×1000px\u00a0\u00a0\u00a03:4", + "790×1053px\u00a0\u00a0\u00a03:4", + "750×1125px\u00a0\u00a0\u00a02:3", + "790×1185px\u00a0\u00a0\u00a02:3", + ], + defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", + }, + model: { + ratios: ["750×1000px\u00a0\u00a0\u00a03:4"], + defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", + }, + video: { + ratios: ["1080×1920px\u00a0\u00a0\u00a09:16", "1080×1440px\u00a0\u00a0\u00a03:4", "1080×1080px\u00a0\u00a0\u00a01:1"], + defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", + }, + hot: { + ratios: ["800×800px\u00a0\u00a0\u00a01:1"], + defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", + }, + }, + specs: ["主图 / SKU 图 800×800px,≤3MB", "详情页宽 750px 或 790px,单张高度≤1546px"], + tip: "建议主图 200-400KB JPG,超过 500KB 会影响加载速度。", + }, + { + label: "京东", + ratios: ["京东主图 / SKU 图 800×800px", "详情页宽 750px", "首图主体占比 ≥80%"], + defaultRatio: "京东主图 / SKU 图 800×800px", + ratioGroups: { + set: { + ratios: ["1000×1000px\u00a0\u00a0\u00a01:1"], + defaultRatio: "1000×1000px\u00a0\u00a0\u00a01:1", + }, + detail: { + ratios: [ + "750×1000px\u00a0\u00a0\u00a03:4", + "990×1320px\u00a0\u00a0\u00a03:4", + "750×1125px\u00a0\u00a0\u00a02:3", + "990×1485px\u00a0\u00a0\u00a02:3", + ], + defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", + }, + model: { + ratios: ["750×1125px\u00a0\u00a0\u00a02:3", "990×1485px\u00a0\u00a0\u00a02:3"], + defaultRatio: "750×1125px\u00a0\u00a0\u00a02:3", + }, + video: { + ratios: ["1080×1920px\u00a0\u00a0\u00a09:16", "1920×1080px\u00a0\u00a0\u00a016:9"], + defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", + }, + hot: { + ratios: ["800×800px\u00a0\u00a0\u00a01:1"], + defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", + }, + }, + specs: ["主图 / SKU 图 800×800px,白底,≤3MB", "详情页宽 750px,首图主体占比 ≥80%"], + }, + { + label: "拼多多", + ratios: ["主图 750×352px", "主图 800×800px", "详情页宽 750px"], + defaultRatio: "主图 750×352px", + ratioGroups: { + set: { + ratios: ["800×800px\u00a0\u00a0\u00a01:1", "750×1000px\u00a0\u00a0\u00a03:4"], + defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", + }, + detail: { + ratios: ["750×1000px\u00a0\u00a0\u00a03:4", "750×1125px\u00a0\u00a0\u00a02:3"], + defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", + }, + model: { + ratios: ["750×1000px\u00a0\u00a0\u00a03:4"], + defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", + }, + video: { + ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"], + defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", + }, + hot: { + ratios: ["800×800px\u00a0\u00a0\u00a01:1"], + defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", + }, + }, + specs: ["主图 750×352px 或 800×800px,≤1MB", "详情页宽 750px,要求纯白底、无水印、无拼接"], + }, + { + label: "抖音电商", + ratios: ["短视频1080×1920px"], + defaultRatio: "短视频1080×1920px", + ratioGroups: { + video: { + ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"], + defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", + }, + hot: { + ratios: ["800×800px\u00a0\u00a0\u00a01:1"], + defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", + }, + }, + specs: ["短视频 1080×1920px,9:16", "30s 内最佳"], + }, + { + label: "亚马逊 Amazon", + ratios: ["主图 ≥1600×1600px", "建议 2000×2000px+", "最小 500×500px"], + defaultRatio: "主图 ≥1600×1600px", + ratioGroups: { + set: { + ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"], + defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1", + }, + detail: { + ratios: ["1600×1600px\u00a0\u00a0\u00a01:1", "1200×1800px\u00a0\u00a0\u00a02:3", "1200×1600px\u00a0\u00a0\u00a03:4"], + defaultRatio: "1200×1800px\u00a0\u00a0\u00a02:3", + }, + model: { + ratios: ["1200×1800px\u00a0\u00a0\u00a02:3"], + defaultRatio: "1200×1800px\u00a0\u00a0\u00a02:3", + }, + video: { + ratios: ["1920×1080px\u00a0\u00a0\u00a016:9"], + defaultRatio: "1920×1080px\u00a0\u00a0\u00a016:9", + }, + hot: { + ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"], + defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1", + }, + }, + specs: ["主图 1600×1600px+,纯白底,≤10MB", "最小 500×500px,建议 2000px+ 以支持缩放"], + aliases: ["亚马逊"], + }, + { + label: "Shopee", + ratios: ["商品主图 1024×1024px", "基础主图 800×800px"], + defaultRatio: "商品主图 1024×1024px", + ratioGroups: { + set: { + ratios: ["800×800px\u00a0\u00a0\u00a01:1"], + defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", + }, + detail: { + ratios: ["750×1000px\u00a0\u00a0\u00a03:4", "750×1125px\u00a0\u00a0\u00a02:3"], + defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", + }, + model: { + ratios: ["750×1000px\u00a0\u00a0\u00a03:4"], + defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", + }, + video: { + ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"], + defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", + }, + hot: { + ratios: ["800×800px\u00a0\u00a0\u00a01:1"], + defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", + }, + }, + specs: ["商品主图推荐 1024×1024px,基础 800×800px", "≤2MB,白底或浅色底"], + aliases: ["虾皮 Shopee/Lazada", "虾皮"], + }, + { + label: "Lazada", + ratios: ["商品主图 800×800px"], + defaultRatio: "商品主图 800×800px", + ratioGroups: { + set: { + ratios: ["800×800px\u00a0\u00a0\u00a01:1"], + defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", + }, + detail: { + ratios: ["750×1000px\u00a0\u00a0\u00a03:4", "750×1125px\u00a0\u00a0\u00a02:3"], + defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", + }, + model: { + ratios: ["750×1000px\u00a0\u00a0\u00a03:4"], + defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", + }, + video: { + ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"], + defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", + }, + hot: { + ratios: ["800×800px\u00a0\u00a0\u00a01:1"], + defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", + }, + }, + specs: ["商品主图 800×800px,1:1"], + }, + { + label: "Instagram", + ratios: ["帖子 1080×1350px", "帖子 1080×1080px", "Stories / Reels 1080×1920px", "头像 320×320px"], + defaultRatio: "帖子 1080×1350px", + ratioGroups: { + set: { + ratios: ["1080×1080px\u00a0\u00a0\u00a01:1", "1080×1350px\u00a0\u00a0\u00a04:5"], + defaultRatio: "1080×1080px\u00a0\u00a0\u00a01:1", + }, + detail: { + ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"], + defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5", + }, + model: { + ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"], + defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5", + }, + video: { + ratios: ["1080×1920px\u00a0\u00a0\u00a09:16", "1080×1350px\u00a0\u00a0\u00a04:5"], + defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", + }, + }, + specs: ["帖子 1080×1350px 或 1080×1080px", "Stories / Reels 封面 1080×1920px,头像 320×320px"], + tip: "建议 ≤8MB JPG。", + aliases: ["Instagram Reels"], + }, + { + label: "速卖通", + ratios: ["主图 800×800px", "主图 1000×1000px+"], + defaultRatio: "主图 800×800px", + ratioGroups: { + set: { + ratios: ["1000×1000px\u00a0\u00a0\u00a01:1"], + defaultRatio: "1000×1000px\u00a0\u00a0\u00a01:1", + }, + detail: { + ratios: ["750×1125px\u00a0\u00a0\u00a02:3", "750×1000px\u00a0\u00a0\u00a03:4"], + defaultRatio: "750×1125px\u00a0\u00a0\u00a02:3", + }, + model: { + ratios: ["750×1125px\u00a0\u00a0\u00a02:3"], + defaultRatio: "750×1125px\u00a0\u00a0\u00a02:3", + }, + video: { + ratios: ["1080×1920px\u00a0\u00a0\u00a09:16", "1920×1080px\u00a0\u00a0\u00a016:9"], + defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", + }, + hot: { + ratios: ["800×800px\u00a0\u00a0\u00a01:1"], + defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", + }, + }, + specs: ["主图建议 800×800px 或更高,1:1", "适合跨境电商主图、SKU 图和场景图"], + }, + { + label: "eBay", + ratios: ["商品图1:1", "白底多角度展示图 1:1"], + defaultRatio: "商品图1:1", + ratioGroups: { + set: { + ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"], + defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1", + }, + detail: { + ratios: ["1000×1500px\u00a0\u00a0\u00a02:3", "1200×1600px\u00a0\u00a0\u00a03:4"], + defaultRatio: "1000×1500px\u00a0\u00a0\u00a02:3", + }, + model: { + ratios: ["1000×1500px\u00a0\u00a0\u00a02:3"], + defaultRatio: "1000×1500px\u00a0\u00a0\u00a02:3", + }, + video: { + ratios: ["1920×1080px\u00a0\u00a0\u00a016:9", "1080×1920px\u00a0\u00a0\u00a09:16"], + defaultRatio: "1920×1080px\u00a0\u00a0\u00a016:9", + }, + hot: { + ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"], + defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1", + }, + }, + specs: ["商品图建议 1:1,主体清晰居中", "适合白底主图和多角度展示图"], + }, + { + label: "TikTok Shop", + ratios: ["商品主图 1:1", "短视频/ 竖版封面 9:16"], + defaultRatio: "商品主图 1:1", + ratioGroups: { + set: { + ratios: ["1280×1280px\u00a0\u00a0\u00a01:1"], + defaultRatio: "1280×1280px\u00a0\u00a0\u00a01:1", + }, + detail: { + ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"], + defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5", + }, + model: { + ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"], + defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5", + }, + video: { + ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"], + defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", + }, + hot: { + ratios: ["800×800px\u00a0\u00a0\u00a01:1"], + defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", + }, + }, + specs: ["商品主图建议 1:1", "短视频竖版封面建议 9:16"], + }, + ], + marketLanguageOptions: [ + { country: "中国", languages: ["中文"] }, + { country: "美国", languages: ["英文"] }, + { country: "加拿大", languages: ["英文", "法文"] }, + { country: "英国", languages: ["英文"] }, + { country: "德国", languages: ["德文"] }, + { country: "法国", languages: ["法文"] }, + { country: "意大利", languages: ["意大利语"] }, + { country: "西班牙", languages: ["西班牙语"] }, + { country: "日本", languages: ["日文"] }, + { country: "韩国", languages: ["韩文"] }, + { country: "澳大利亚", languages: ["英文"] }, + { country: "新加坡", languages: ["英文", "中文"] }, + { country: "马来西亚", languages: ["马来语", "英文", "中文"] }, + { country: "印尼", languages: ["印度尼西亚语", "英文"] }, + { country: "越南", languages: ["越南语", "英文"] }, + { country: "泰国", languages: ["泰语", "英文"] }, + { country: "菲律宾", languages: ["菲律宾语(他加禄语)", "英文"] }, + { country: "巴西", languages: ["葡萄牙语"] }, + { country: "墨西哥", languages: ["西班牙语"] }, + { country: "智利", languages: ["西班牙语"] }, + { country: "哥伦比亚", languages: ["西班牙语"] }, + { country: "阿联酋", languages: ["阿拉伯语", "英文"] }, + { country: "沙特阿拉伯", languages: ["阿拉伯语", "英文"] }, + { country: "俄罗斯", languages: ["俄语"] }, + { country: "波兰", languages: ["波兰语"] }, + ], + languageAliases: { + "英文": "英文", + "中文": "中文", + "英语": "英文", + "日语": "日文", + "日文": "日文", + "德语": "德文", + "德文": "德文", + "法语": "法文", + "法文": "法文", + "韩语": "韩文", + "韩文": "韩文", + "西文": "西班牙语", + "西班牙语": "西班牙语", + "葡文": "葡萄牙语", + "葡萄牙语": "葡萄牙语", + "印尼语": "印度尼西亚语", + "印度尼西亚语": "印度尼西亚语", + "菲律宾语": "菲律宾语(他加禄语)", + "菲律宾语(他加禄语)": "菲律宾语(他加禄语)", + }, + legacyPlatformAliases: { + "淘宝/天猫": "淘宝/天猫", + "京东": "京东", + "拼多多": "拼多多", + "抖音电商": "抖音电商", + "亚马逊Amazon": "亚马逊 Amazon", + "速卖通": "速卖通", + }, + domesticPlatformLabels: ["淘宝/天猫", "京东", "拼多多", "抖音电商"], + domesticPlatformLanguages: ["中文"], + defaultEcommercePlatform: "淘宝/天猫", +}; + +interface PlatformRulesPayload { + name: string; + config: Partial; +} + +let cached: PlatformRulesData | null = null; +let loadPromise: Promise | null = null; + +function isNonEmptyArray(value: unknown): boolean { + return Array.isArray(value) && value.length > 0; +} + +// 合并 API 返回与 fallback:仅当 API 字段有效(非空)时覆盖,避免后端漏配某字段导致 UI 空白。 +function mergeWithFallback(config: Partial): PlatformRulesData { + return { + platformSpecOptions: isNonEmptyArray(config.platformSpecOptions) + ? (config.platformSpecOptions as EcommercePlatformSpec[]) + : FALLBACK_PLATFORM_RULES.platformSpecOptions, + marketLanguageOptions: isNonEmptyArray(config.marketLanguageOptions) + ? (config.marketLanguageOptions as MarketLanguageOption[]) + : FALLBACK_PLATFORM_RULES.marketLanguageOptions, + languageAliases: + config.languageAliases && typeof config.languageAliases === "object" + ? config.languageAliases + : FALLBACK_PLATFORM_RULES.languageAliases, + legacyPlatformAliases: + config.legacyPlatformAliases && typeof config.legacyPlatformAliases === "object" + ? config.legacyPlatformAliases + : FALLBACK_PLATFORM_RULES.legacyPlatformAliases, + domesticPlatformLabels: isNonEmptyArray(config.domesticPlatformLabels) + ? (config.domesticPlatformLabels as string[]) + : FALLBACK_PLATFORM_RULES.domesticPlatformLabels, + domesticPlatformLanguages: isNonEmptyArray(config.domesticPlatformLanguages) + ? (config.domesticPlatformLanguages as string[]) + : FALLBACK_PLATFORM_RULES.domesticPlatformLanguages, + defaultEcommercePlatform: + typeof config.defaultEcommercePlatform === "string" && config.defaultEcommercePlatform.trim() + ? config.defaultEcommercePlatform + : FALLBACK_PLATFORM_RULES.defaultEcommercePlatform, + }; +} + +async function fetchPlatformRules(): Promise { + const payload = await serverRequest( + "public/config/profile?name=web-ecommerce-platform-rules", + { maxRetries: 2, timeoutMs: 8_000, fallbackMessage: "加载电商平台规则失败" }, + ); + return mergeWithFallback(payload?.config ?? {}); +} + +/** 预加载平台规则。App 启动 gating 调用,await 其完成(带超时,失败用 fallback)。可安全重复调用。 */ +export async function preloadPlatformRules(): Promise { + if (loadPromise) return loadPromise.then(() => undefined); + loadPromise = fetchPlatformRules() + .then((data) => { + cached = data; + return data; + }) + .catch((error) => { + console.warn("[platformRules] 加载失败,使用 fallback 数据", error); + cached = null; + loadPromise = null; + return FALLBACK_PLATFORM_RULES; + }); + return loadPromise.then(() => undefined); +} + +/** 同步获取平台规则。未加载时返回 fallback(=当前生产值,永远可用)。 */ +export function getPlatformRules(): PlatformRulesData { + return cached ?? FALLBACK_PLATFORM_RULES; +} diff --git a/src/api/publicConfigClient.ts b/src/api/publicConfigClient.ts new file mode 100644 index 0000000..5e80860 --- /dev/null +++ b/src/api/publicConfigClient.ts @@ -0,0 +1,65 @@ +// 前端公网配置客户端。 +// 从 GET /api/public/config/profile?name=web-public-config 拉取运行时配置, +// 包括 OSS 公网 base URL 与 logo URL。 +// 按 AGENTS.md 规则 1/4/5:这些环境权威数据不硬编码在前端源码,由 API 下发。 +// +// 设计:进程内单例缓存 + promise 去重,App 启动时预加载一次, +// 之后 getOssPublicBaseUrl() / getLogoUrl() 同步返回缓存值。 +// API 不可用时回退到 FALLBACK 值(当前生产 bucket),保证渐进可用。 + +import { serverRequest } from "./serverConnection"; + +interface PublicConfigPayload { + name: string; + config: { + ossPublicBaseUrl?: string; + logoUrl?: string; + }; + description?: string; + updatedAt?: string; +} + +// Fallback:API 不可用或未加载时的兜底值,保证首屏不白屏。 +// 这些值仅作为降级,正式来源是 API 返回的 config。 +const FALLBACK_OSS_PUBLIC_BASE_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com"; +const FALLBACK_LOGO_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/muban/logo.png"; + +let cachedConfig: PublicConfigPayload["config"] | null = null; +let loadPromise: Promise | null = null; + +async function fetchPublicConfig(): Promise { + const payload = await serverRequest("public/config/profile?name=web-public-config", { + // 公开端点,无需 token。 + maxRetries: 2, + fallbackMessage: "加载公网配置失败", + }); + return payload?.config ?? {}; +} + +/** 预加载公网配置。App 启动时调用一次,后续同步读取缓存。可安全重复调用(promise 去重)。 */ +export async function preloadPublicConfig(): Promise { + if (loadPromise) return loadPromise.then(() => undefined); + loadPromise = fetchPublicConfig() + .then((config) => { + cachedConfig = config; + return config; + }) + .catch((error) => { + // 加载失败不阻断启动,用 fallback 值;记录后允许后续重试。 + console.warn("[publicConfig] 加载失败,使用 fallback 值", error); + cachedConfig = null; + loadPromise = null; + return {}; + }); + return loadPromise.then(() => undefined); +} + +/** 同步获取 OSS 公网 base URL。未加载时返回 fallback。 */ +export function getOssPublicBaseUrl(): string { + return cachedConfig?.ossPublicBaseUrl?.trim() || FALLBACK_OSS_PUBLIC_BASE_URL; +} + +/** 同步获取 logo URL。未加载时返回 fallback。 */ +export function getLogoUrl(): string { + return cachedConfig?.logoUrl?.trim() || FALLBACK_LOGO_URL; +} diff --git a/src/components/Topbar.tsx b/src/components/Topbar.tsx index 9bba4af..1eee5ad 100644 --- a/src/components/Topbar.tsx +++ b/src/components/Topbar.tsx @@ -10,6 +10,7 @@ import { WalletOutlined, } from "@ant-design/icons"; import { LocalAvatar } from "./LocalAvatar"; +import { getLogoUrl } from "../api/publicConfigClient"; import type { WebUserSession } from "../types"; interface TopbarProps { @@ -110,7 +111,7 @@ export function Topbar({ onClick={onOpenWorkspace} > OmniAI 电商智能体 diff --git a/src/components/toast/toastStore.ts b/src/components/toast/toastStore.ts index f014179..f0e418a 100644 --- a/src/components/toast/toastStore.ts +++ b/src/components/toast/toastStore.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; export type ToastType = "success" | "error" | "info"; diff --git a/src/data/ossAssets.ts b/src/data/ossAssets.ts index 5065378..75d2d0b 100644 --- a/src/data/ossAssets.ts +++ b/src/data/ossAssets.ts @@ -1,7 +1,10 @@ -const OSS_PUBLIC_BASE_URL = "https://stringtest.oss-cn-hangzhou.aliyuncs.com"; +// OSS 公网 base URL 由 API 下发(AGENTS.md 规则 1/5), +// 见 src/api/publicConfigClient.ts。ossAssets 在模块加载时同步取缓存值, +// App 启动时 preloadPublicConfig() 已预加载;未加载时 getOssPublicBaseUrl() 返回 fallback。 +import { getOssPublicBaseUrl } from "../api/publicConfigClient"; function oss(path: string): string { - return `${OSS_PUBLIC_BASE_URL}/${path.replace(/^\/+/, "")}`; + return `${getOssPublicBaseUrl()}/${path.replace(/^\/+/, "")}`; } function muban(path: string): string { diff --git a/src/features/ecommerce/EcommercePage.tsx b/src/features/ecommerce/EcommercePage.tsx index e7dafd2..9390eec 100644 --- a/src/features/ecommerce/EcommercePage.tsx +++ b/src/features/ecommerce/EcommercePage.tsx @@ -4,7 +4,6 @@ import { CloudUploadOutlined, CloseOutlined, DeleteOutlined, - DownloadOutlined, EditOutlined, FireOutlined, FileImageOutlined, @@ -27,15 +26,8 @@ import { } from "@ant-design/icons"; import { ArrowsCounterClockwise, - Fire, - FrameCorners, - Gift, MagicWand, - Mountains, PaperPlaneRight, - ShoppingBag, - User, - VideoCamera, } from "@phosphor-icons/react"; import { Fragment, useEffect, useMemo, useRef, useState, type CSSProperties, type ChangeEvent, type DragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; import { createPortal } from "react-dom"; @@ -47,14 +39,64 @@ import { EcommerceProgressBar } from "./EcommerceProgressBar"; import ImageMentionMenu, { getImageMentionQuery, insertImageMentionValue, type MentionImageOption } from "./ImageMentionMenu"; import EcommerceVideoWorkspace from "./EcommerceVideoWorkspace"; import EcommerceVideoHistoryPanel from "./panels/EcommerceVideoHistoryPanel"; +import ProductSetHostingModal from "./panels/ProductSetHostingModal"; +import ProductSetPreviewModal, { type ProductSetPreviewSelection } from "./panels/ProductSetPreviewModal"; +import CommandHistorySidebar from "./panels/CommandHistorySidebar"; +import WatermarkToolPage from "./panels/WatermarkToolPage"; import EcommerceDetailPanel from "./panels/EcommerceDetailPanel"; import EcommerceSetPanel from "./panels/EcommerceSetPanel"; import EcommerceTryOnPanel from "./panels/EcommerceTryOnPanel"; import EcommerceClonePanel from "./panels/EcommerceClonePanel"; import EcommerceCopywritingPanel from "./panels/EcommerceCopywritingPanel"; -import { ecommerceOssScopes, saveUnifiedEcommerceGenerationRecord, deleteEcommerceGenerationRecord } from "./ecommerceGenerationPersistence"; +import EcommerceOneClickVideoPanel from "./panels/EcommerceOneClickVideoPanel"; +import { ecommerceOssScopes, listEcommerceGenerationHistory, saveUnifiedEcommerceGenerationRecord, deleteEcommerceGenerationRecord } from "./ecommerceGenerationPersistence"; import { downloadResultAsset } from "../workbench/workbenchDownload"; -import type { CloneOutputKey, ProductSetOutputKey } from "./utils/platformRules"; +import { + defaultCloneOutput, + defaultEcommercePlatform, + defaultProductSetOutput, + getPlatformDefaultLanguage, + getPlatformDefaultRatio, + getPlatformLanguageOptions, + getPlatformRatioOptions, + marketLanguageOptions, + marketOptions, + normalizeLanguageForPlatform, + normalizeMarket, + normalizePlatform, + normalizeRatioForPlatform, + platformOptions, +} from "./utils/platformRules"; +import type { + CloneOutputKey, + ProductSetOutputKey, +} from "./utils/platformRules"; +import { + buildHistoryTurnFromRecord, + cloneLatestSettingStorageKey, + defaultCloneDetailModuleIds, + defaultCloneSetCounts, + ecommerceHistoryStorageKey, + getTurnResults, + normalizeEcommerceHistoryRecord, + normalizeEcommerceHistoryTurn, + readEcommerceHistoryRecords, + writeCloneLatestSetting, + writeEcommerceHistoryRecords, +} from "./utils/clonePersistence"; +import type { + CloneImageItem, + CloneModelPanelTab, + CloneReferenceMode, + CloneReplicateLevelKey, + CloneResult, + CloneSavedSetting, + CloneSetCountKey, + CloneVideoQualityKey, + EcommerceHistoryRecord, + EcommerceHistoryStatus, + EcommerceHistoryTurn, +} from "./utils/clonePersistence"; const smartCutoutColorPresets = [ "#ffffff", @@ -181,91 +223,6 @@ const buildInspirationPrompt = (title: string, meta: string): string => { return points.length ? `${base}。风格要点:${points.join("、")}。` : `${base}。`; }; -const clampNumber = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); - -const normalizeHexColor = (value: string) => { - const clean = value.trim().replace(/^#/, ""); - if (!/^[0-9a-fA-F]{6}$/.test(clean)) return null; - return `#${clean.toLowerCase()}`; -}; - -const hexToRgb = (value: string) => { - const normalized = normalizeHexColor(value); - if (!normalized) return null; - const numeric = Number.parseInt(normalized.slice(1), 16); - return { - r: (numeric >> 16) & 255, - g: (numeric >> 8) & 255, - b: numeric & 255, - }; -}; - -const rgbToHex = (r: number, g: number, b: number) => - `#${[r, g, b].map((item) => clampNumber(Math.round(item), 0, 255).toString(16).padStart(2, "0")).join("")}`; - -const parseSmartCutoutAspect = (aspect: string) => { - const match = aspect.match(/(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/); - if (!match) return null; - const width = Number(match[1]); - const height = Number(match[2]); - if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null; - return width / height; -}; - -const parseSmartCutoutPercent = (value: string, fallback: number) => { - const numeric = Number(value.replace("%", "")); - if (!Number.isFinite(numeric)) return fallback; - return clampNumber(numeric / 100, 0.05, 1); -}; - -const hsvToRgb = (h: number, s: number, v: number) => { - const hue = ((h % 360) + 360) % 360; - const saturation = clampNumber(s, 0, 100) / 100; - const value = clampNumber(v, 0, 100) / 100; - const chroma = value * saturation; - const x = chroma * (1 - Math.abs(((hue / 60) % 2) - 1)); - const match = value - chroma; - const [red, green, blue] = - hue < 60 - ? [chroma, x, 0] - : hue < 120 - ? [x, chroma, 0] - : hue < 180 - ? [0, chroma, x] - : hue < 240 - ? [0, x, chroma] - : hue < 300 - ? [x, 0, chroma] - : [chroma, 0, x]; - return { - r: (red + match) * 255, - g: (green + match) * 255, - b: (blue + match) * 255, - }; -}; - -const hexToHsv = (value: string) => { - const rgb = hexToRgb(value) ?? { r: 255, g: 255, b: 255 }; - const red = rgb.r / 255; - const green = rgb.g / 255; - const blue = rgb.b / 255; - const max = Math.max(red, green, blue); - const min = Math.min(red, green, blue); - const delta = max - min; - const hue = - delta === 0 - ? 0 - : max === red - ? 60 * (((green - blue) / delta) % 6) - : max === green - ? 60 * ((blue - red) / delta + 2) - : 60 * ((red - green) / delta + 4); - return { - h: Math.round((hue + 360) % 360), - s: max === 0 ? 0 : Math.round((delta / max) * 100), - v: Math.round(max * 100), - }; -}; import { aiGenerationClient } from "../../api/aiGenerationClient"; import { ServerRequestError } from "../../api/serverConnection"; import { waitForTask } from "../../api/taskSubscription"; @@ -277,6 +234,25 @@ import { summarizeRejectedImages, validateEcommerceImageFiles, } from "./ecommerceImageValidation"; +import { + clampNumber, + hexToHsv, + hexToRgb, + hsvToRgb, + normalizeHexColor, + parseSmartCutoutAspect, + parseSmartCutoutPercent, + rgbToHex, +} from "./utils/colorUtils"; +import { + formatRatioDisplayValue, + getQuickSetRatioValue, + getRatioDisplayParts, + normalizeRatioForApi, + normalizeRatioToken, + parseRatioToAspectCss, + quickSetRatioOptions, +} from "./utils/ratioUtils"; interface ProductClonePageProps { @@ -285,9 +261,10 @@ interface ProductClonePageProps { type ProductCloneStatus = "idle" | "ready" | "generating" | "done" | "failed"; type CommerceScenarioKey = "popular" | "poster" | "mainImage" | "scene" | "festival" | "model" | "background" | "retouch" | "salesVideo"; -type CloneSetCountKey = "selling" | "white" | "scene"; -type CloneModelPanelTab = "scene" | "model"; -type CloneVideoQualityKey = "standard" | "high" | "ultra"; +type CommerceDefaultImageScenarioKey = Exclude; +type CommerceDefaultIntent = + | { kind: "image"; scenario: CommerceDefaultImageScenarioKey } + | { kind: "video"; scenario: "salesVideo" }; type ProductSetStatus = "idle" | "ready" | "generating" | "done" | "failed"; type ProductKitToolKey = "set" | "detail" | "wear" | "clone"; type ComposerMenuKey = "mode" | "platform" | "language" | "ratio" | "settings" | "assetLibrary" | "workMode" | "aiWrite"; @@ -295,8 +272,6 @@ type ComposerAssetTabKey = "recent" | "recipe" | "model"; type ComposerWorkModeKey = "quick" | "think"; type CloneBasicSelectKey = "platform" | "market" | "language" | "ratio"; type CloneModelSelectKey = "gender" | "age" | "ethnicity" | "body"; -type CloneReferenceMode = "upload" | "link"; -type CloneReplicateLevelKey = "style" | "high"; type CloneTemplateAsset = { id: string; title: string; @@ -313,25 +288,6 @@ type TryOnModelSource = "ai" | "library"; type TryOnStatus = "idle" | "modeling" | "ready" | "generating" | "done" | "failed"; type DetailStatus = "idle" | "ready" | "generating" | "done" | "failed"; -interface CloneImageItem { - id: string; - src: string; - name: string; - file?: File; - width?: number; - height?: number; - format?: string; - mimeType?: string; - ossKey?: string; -} - -interface CloneResult { - id: string; - src: string; - label: string; - type?: "image" | "video"; -} - interface CanvasNode { id: string; mode: string; @@ -357,87 +313,6 @@ interface PreviewTouchGesture { startCenter: { x: number; y: number }; } -interface CloneSavedSetting { - id: string; - name: string; - savedAt: string; - output: CloneOutputKey; - platform: string; - market: string; - language: string; - ratio: string; - setCounts: Record; - detailModules: string[]; - modelPanelTab: CloneModelPanelTab; - modelScenes: string[]; - modelCustomScene: string; - modelGender: string; - modelAge: string; - modelEthnicity: string; - modelBody: string; - modelAppearance: string; - videoQuality: CloneVideoQualityKey; - videoDurationSeconds: number; - videoSmart: boolean; - referenceMode?: CloneReferenceMode; - replicateLevel?: CloneReplicateLevelKey; - requirement: string; -} - -type EcommerceHistoryStatus = "generating" | "done" | "failed"; - -interface EcommerceHistoryTurn { - id: string; - createdAt: number; - status: EcommerceHistoryStatus; - errorMessage?: string; - output: CloneOutputKey; - platform: string; - market: string; - language: string; - ratio: string; - requirement: string; - productImages: CloneImageItem[]; - results: CloneResult[]; - setResultImages: string[]; - setCounts: Record; - detailModules: string[]; - modelScenes: string[]; - referenceImages: CloneImageItem[]; - replicateLevel: CloneReplicateLevelKey; -} - -interface EcommerceHistoryRecord { - id: string; - title: string; - createdAt: number; - status?: EcommerceHistoryStatus; - errorMessage?: string; - output: CloneOutputKey; - platform: string; - market: string; - language: string; - ratio: string; - requirement: string; - productImages: CloneImageItem[]; - results: CloneResult[]; - setResultImages: string[]; - setCounts: Record; - detailModules: string[]; - modelScenes: string[]; - referenceImages: CloneImageItem[]; - replicateLevel: CloneReplicateLevelKey; - turns?: EcommerceHistoryTurn[]; -} - -interface ProductSetPreviewSelection { - src: string; - label: string; - nodeId?: string; - cardId?: string; - removable?: boolean; -} - interface EcommerceImagePromptOptions { gender?: string; age?: string; @@ -450,13 +325,6 @@ interface EcommerceImagePromptOptions { detailModules?: string[]; } -type PlatformRatioModeKey = ProductSetOutputKey | "hot"; - -interface PlatformRatioGroup { - ratios: string[]; - defaultRatio: string; -} - const sideTools: Array<{ key: ProductKitToolKey; label: string; icon: ReactNode }> = [ { key: "set", label: "商品套图", icon: }, { key: "detail", label: "A+详情", icon: }, @@ -464,324 +332,6 @@ const sideTools: Array<{ key: ProductKitToolKey; label: string; icon: ReactNode { key: "clone", label: "电商AI作图", icon: }, ]; -const platformSpecOptions: Array<{ - label: string; - ratios: string[]; - defaultRatio: string; - ratioGroups?: Partial>; - specs: string[]; - tip?: string; - aliases?: string[]; -}> = [ - { - label: "淘宝/天猫", - ratios: ["淘宝主图 / SKU 图 800×800px", "详情页宽 750px", "详情页宽 790px"], - defaultRatio: "淘宝主图 / SKU 图 800×800px", - ratioGroups: { - set: { - ratios: ["1000×1000px\u00a0\u00a0\u00a01:1", "800×800px\u00a0\u00a0\u00a01:1"], - defaultRatio: "1000×1000px\u00a0\u00a0\u00a01:1", - }, - detail: { - ratios: [ - "750×1000px\u00a0\u00a0\u00a03:4", - "790×1053px\u00a0\u00a0\u00a03:4", - "750×1125px\u00a0\u00a0\u00a02:3", - "790×1185px\u00a0\u00a0\u00a02:3", - ], - defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", - }, - model: { - ratios: ["750×1000px\u00a0\u00a0\u00a03:4"], - defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", - }, - video: { - ratios: ["1080×1920px\u00a0\u00a0\u00a09:16", "1080×1440px\u00a0\u00a0\u00a03:4", "1080×1080px\u00a0\u00a0\u00a01:1"], - defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", - }, - hot: { - ratios: ["800×800px\u00a0\u00a0\u00a01:1"], - defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", - }, - }, - specs: ["主图 / SKU 图 800×800px,≤3MB", "详情页宽 750px 或 790px,单张高度≤1546px"], - tip: "建议主图 200-400KB JPG,超过 500KB 会影响加载速度。", - }, - { - label: "京东", - ratios: ["京东主图 / SKU 图 800×800px", "详情页宽 750px", "首图主体占比 ≥80%"], - defaultRatio: "京东主图 / SKU 图 800×800px", - ratioGroups: { - set: { - ratios: ["1000×1000px\u00a0\u00a0\u00a01:1"], - defaultRatio: "1000×1000px\u00a0\u00a0\u00a01:1", - }, - detail: { - ratios: [ - "750×1000px\u00a0\u00a0\u00a03:4", - "990×1320px\u00a0\u00a0\u00a03:4", - "750×1125px\u00a0\u00a0\u00a02:3", - "990×1485px\u00a0\u00a0\u00a02:3", - ], - defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", - }, - model: { - ratios: ["750×1125px\u00a0\u00a0\u00a02:3", "990×1485px\u00a0\u00a0\u00a02:3"], - defaultRatio: "750×1125px\u00a0\u00a0\u00a02:3", - }, - video: { - ratios: ["1080×1920px\u00a0\u00a0\u00a09:16", "1920×1080px\u00a0\u00a0\u00a016:9"], - defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", - }, - hot: { - ratios: ["800×800px\u00a0\u00a0\u00a01:1"], - defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", - }, - }, - specs: ["主图 / SKU 图 800×800px,白底,≤3MB", "详情页宽 750px,首图主体占比 ≥80%"], - }, - { - label: "拼多多", - ratios: ["主图 750×352px", "主图 800×800px", "详情页宽 750px"], - defaultRatio: "主图 750×352px", - ratioGroups: { - set: { - ratios: ["800×800px\u00a0\u00a0\u00a01:1", "750×1000px\u00a0\u00a0\u00a03:4"], - defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", - }, - detail: { - ratios: ["750×1000px\u00a0\u00a0\u00a03:4", "750×1125px\u00a0\u00a0\u00a02:3"], - defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", - }, - model: { - ratios: ["750×1000px\u00a0\u00a0\u00a03:4"], - defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", - }, - video: { - ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"], - defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", - }, - hot: { - ratios: ["800×800px\u00a0\u00a0\u00a01:1"], - defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", - }, - }, - specs: ["主图 750×352px 或 800×800px,≤1MB", "详情页宽 750px,要求纯白底、无水印、无拼接"], - }, - { - label: "抖音电商", - ratios: ["短视频1080×1920px"], - defaultRatio: "短视频1080×1920px", - ratioGroups: { - video: { - ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"], - defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", - }, - hot: { - ratios: ["800×800px\u00a0\u00a0\u00a01:1"], - defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", - }, - }, - specs: ["短视频 1080×1920px,9:16", "30s 内最佳"], - }, - { - label: "亚马逊 Amazon", - ratios: ["主图 ≥1600×1600px", "建议 2000×2000px+", "最小 500×500px"], - defaultRatio: "主图 ≥1600×1600px", - ratioGroups: { - set: { - ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"], - defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1", - }, - detail: { - ratios: ["1600×1600px\u00a0\u00a0\u00a01:1", "1200×1800px\u00a0\u00a0\u00a02:3", "1200×1600px\u00a0\u00a0\u00a03:4"], - defaultRatio: "1200×1800px\u00a0\u00a0\u00a02:3", - }, - model: { - ratios: ["1200×1800px\u00a0\u00a0\u00a02:3"], - defaultRatio: "1200×1800px\u00a0\u00a0\u00a02:3", - }, - video: { - ratios: ["1920×1080px\u00a0\u00a0\u00a016:9"], - defaultRatio: "1920×1080px\u00a0\u00a0\u00a016:9", - }, - hot: { - ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"], - defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1", - }, - }, - specs: ["主图 1600×1600px+,纯白底,≤10MB", "最小 500×500px,建议 2000px+ 以支持缩放"], - aliases: ["亚马逊"], - }, - { - label: "Shopee", - ratios: ["商品主图 1024×1024px", "基础主图 800×800px"], - defaultRatio: "商品主图 1024×1024px", - ratioGroups: { - set: { - ratios: ["800×800px\u00a0\u00a0\u00a01:1"], - defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", - }, - detail: { - ratios: ["750×1000px\u00a0\u00a0\u00a03:4", "750×1125px\u00a0\u00a0\u00a02:3"], - defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", - }, - model: { - ratios: ["750×1000px\u00a0\u00a0\u00a03:4"], - defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", - }, - video: { - ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"], - defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", - }, - hot: { - ratios: ["800×800px\u00a0\u00a0\u00a01:1"], - defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", - }, - }, - specs: ["商品主图推荐 1024×1024px,基础 800×800px", "≤2MB,白底或浅色底"], - aliases: ["虾皮 Shopee/Lazada", "虾皮"], - }, - { - label: "Lazada", - ratios: ["商品主图 800×800px"], - defaultRatio: "商品主图 800×800px", - ratioGroups: { - set: { - ratios: ["800×800px\u00a0\u00a0\u00a01:1"], - defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", - }, - detail: { - ratios: ["750×1000px\u00a0\u00a0\u00a03:4", "750×1125px\u00a0\u00a0\u00a02:3"], - defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", - }, - model: { - ratios: ["750×1000px\u00a0\u00a0\u00a03:4"], - defaultRatio: "750×1000px\u00a0\u00a0\u00a03:4", - }, - video: { - ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"], - defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", - }, - hot: { - ratios: ["800×800px\u00a0\u00a0\u00a01:1"], - defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", - }, - }, - specs: ["商品主图 800×800px,1:1"], - }, - { - label: "Instagram", - ratios: ["帖子 1080×1350px", "帖子 1080×1080px", "Stories / Reels 1080×1920px", "头像 320×320px"], - defaultRatio: "帖子 1080×1350px", - ratioGroups: { - set: { - ratios: ["1080×1080px\u00a0\u00a0\u00a01:1", "1080×1350px\u00a0\u00a0\u00a04:5"], - defaultRatio: "1080×1080px\u00a0\u00a0\u00a01:1", - }, - detail: { - ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"], - defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5", - }, - model: { - ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"], - defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5", - }, - video: { - ratios: ["1080×1920px\u00a0\u00a0\u00a09:16", "1080×1350px\u00a0\u00a0\u00a04:5"], - defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", - }, - }, - specs: ["帖子 1080×1350px 或 1080×1080px", "Stories / Reels 封面 1080×1920px,头像 320×320px"], - tip: "建议 ≤8MB JPG。", - aliases: ["Instagram Reels"], - }, - { - label: "速卖通", - ratios: ["主图 800×800px", "主图 1000×1000px+"], - defaultRatio: "主图 800×800px", - ratioGroups: { - set: { - ratios: ["1000×1000px\u00a0\u00a0\u00a01:1"], - defaultRatio: "1000×1000px\u00a0\u00a0\u00a01:1", - }, - detail: { - ratios: ["750×1125px\u00a0\u00a0\u00a02:3", "750×1000px\u00a0\u00a0\u00a03:4"], - defaultRatio: "750×1125px\u00a0\u00a0\u00a02:3", - }, - model: { - ratios: ["750×1125px\u00a0\u00a0\u00a02:3"], - defaultRatio: "750×1125px\u00a0\u00a0\u00a02:3", - }, - video: { - ratios: ["1080×1920px\u00a0\u00a0\u00a09:16", "1920×1080px\u00a0\u00a0\u00a016:9"], - defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", - }, - hot: { - ratios: ["800×800px\u00a0\u00a0\u00a01:1"], - defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", - }, - }, - specs: ["主图建议 800×800px 或更高,1:1", "适合跨境电商主图、SKU 图和场景图"], - }, - { - label: "eBay", - ratios: ["商品图1:1", "白底多角度展示图 1:1"], - defaultRatio: "商品图1:1", - ratioGroups: { - set: { - ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"], - defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1", - }, - detail: { - ratios: ["1000×1500px\u00a0\u00a0\u00a02:3", "1200×1600px\u00a0\u00a0\u00a03:4"], - defaultRatio: "1000×1500px\u00a0\u00a0\u00a02:3", - }, - model: { - ratios: ["1000×1500px\u00a0\u00a0\u00a02:3"], - defaultRatio: "1000×1500px\u00a0\u00a0\u00a02:3", - }, - video: { - ratios: ["1920×1080px\u00a0\u00a0\u00a016:9", "1080×1920px\u00a0\u00a0\u00a09:16"], - defaultRatio: "1920×1080px\u00a0\u00a0\u00a016:9", - }, - hot: { - ratios: ["1600×1600px\u00a0\u00a0\u00a01:1"], - defaultRatio: "1600×1600px\u00a0\u00a0\u00a01:1", - }, - }, - specs: ["商品图建议 1:1,主体清晰居中", "适合白底主图和多角度展示图"], - }, - { - label: "TikTok Shop", - ratios: ["商品主图 1:1", "短视频/ 竖版封面 9:16"], - defaultRatio: "商品主图 1:1", - ratioGroups: { - set: { - ratios: ["1280×1280px\u00a0\u00a0\u00a01:1"], - defaultRatio: "1280×1280px\u00a0\u00a0\u00a01:1", - }, - detail: { - ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"], - defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5", - }, - model: { - ratios: ["1080×1350px\u00a0\u00a0\u00a04:5"], - defaultRatio: "1080×1350px\u00a0\u00a0\u00a04:5", - }, - video: { - ratios: ["1080×1920px\u00a0\u00a0\u00a09:16"], - defaultRatio: "1080×1920px\u00a0\u00a0\u00a09:16", - }, - hot: { - ratios: ["800×800px\u00a0\u00a0\u00a01:1"], - defaultRatio: "800×800px\u00a0\u00a0\u00a01:1", - }, - }, - specs: ["商品主图建议 1:1", "短视频竖版封面建议 9:16"], - }, -]; -const platformOptions = platformSpecOptions.map((option) => option.label); const getPlatformLogoText = (value: string) => { const normalized = value.toLowerCase(); if (value.includes("淘宝") || value.includes("天猫")) return "淘"; @@ -832,223 +382,6 @@ const renderPlatformLogo = (value: string) => { ); }; -const marketLanguageOptions: Array<{ country: string; languages: string[] }> = [ - { country: "中国", languages: ["中文"] }, - { country: "美国", languages: ["英文"] }, - { country: "加拿大", languages: ["英文", "法文"] }, - { country: "英国", languages: ["英文"] }, - { country: "德国", languages: ["德文"] }, - { country: "法国", languages: ["法文"] }, - { country: "意大利", languages: ["意大利语"] }, - { country: "西班牙", languages: ["西班牙语"] }, - { country: "日本", languages: ["日文"] }, - { country: "韩国", languages: ["韩文"] }, - { country: "澳大利亚", languages: ["英文"] }, - { country: "新加坡", languages: ["英文", "中文"] }, - { country: "马来西亚", languages: ["马来语", "英文", "中文"] }, - { country: "印尼", languages: ["印度尼西亚语", "英文"] }, - { country: "越南", languages: ["越南语", "英文"] }, - { country: "泰国", languages: ["泰语", "英文"] }, - { country: "菲律宾", languages: ["菲律宾语(他加禄语)", "英文"] }, - { country: "巴西", languages: ["葡萄牙语"] }, - { country: "墨西哥", languages: ["西班牙语"] }, - { country: "智利", languages: ["西班牙语"] }, - { country: "哥伦比亚", languages: ["西班牙语"] }, - { country: "阿联酋", languages: ["阿拉伯语", "英文"] }, - { country: "沙特阿拉伯", languages: ["阿拉伯语", "英文"] }, - { country: "俄罗斯", languages: ["俄语"] }, - { country: "波兰", languages: ["波兰语"] }, -]; -const marketOptions = marketLanguageOptions.map((option) => option.country); -const languageOptions = Array.from(new Set(marketLanguageOptions.flatMap((option) => option.languages))); -const languageAliases: Record = { - "英文": "英文", - "中文": "中文", - "英语": "英文", - "日语": "日文", - "日文": "日文", - "德语": "德文", - "德文": "德文", - "法语": "法文", - "法文": "法文", - "韩语": "韩文", - "韩文": "韩文", - "西文": "西班牙语", - "西班牙语": "西班牙语", - "葡文": "葡萄牙语", - "葡萄牙语": "葡萄牙语", - "印尼语": "印度尼西亚语", - "印度尼西亚语": "印度尼西亚语", - "菲律宾语": "菲律宾语(他加禄语)", - "菲律宾语(他加禄语)": "菲律宾语(他加禄语)", -}; -const defaultPlatformSpec = platformSpecOptions[0]!; -const getPlatformSpec = (value: string) => - platformSpecOptions.find((option) => option.label === value || option.aliases?.includes(value)) ?? defaultPlatformSpec; -const legacyPlatformAliases: Record = { - "淘宝/天猫": "淘宝/天猫", - "京东": "京东", - "拼多多": "拼多多", - "抖音电商": "抖音电商", - "亚马逊Amazon": "亚马逊 Amazon", - "速卖通": "速卖通", -}; -const normalizePlatform = (value: string) => getPlatformSpec(legacyPlatformAliases[value] ?? value).label; -const domesticPlatformLabels = new Set(["淘宝/天猫", "京东", "拼多多", "抖音电商"]); -const domesticPlatformLanguages = ["中文"]; -const isDomesticPlatform = (platformValue: string) => domesticPlatformLabels.has(normalizePlatform(platformValue)); -const getPlatformRatioGroup = (value: string, mode?: PlatformRatioModeKey): PlatformRatioGroup => { - const platformSpec = getPlatformSpec(value); - return (mode ? platformSpec.ratioGroups?.[mode] : null) ?? { - ratios: platformSpec.ratios, - defaultRatio: platformSpec.defaultRatio, - }; -}; -const getPlatformRatioOptions = (value: string, mode?: PlatformRatioModeKey) => getPlatformRatioGroup(value, mode).ratios; -const getPlatformDefaultRatio = (value: string, mode?: PlatformRatioModeKey) => getPlatformRatioGroup(value, mode).defaultRatio; -const getUniqueRatioOptions = (ratios: string[]) => Array.from(new Set(ratios)); -const normalizeRatioToken = (value: string) => - value - .replaceAll("\u00a0", " ") - .replaceAll("脳", "×") - .replaceAll("*", "×") - .replaceAll(":", ":") - .replace(/锛\?/g, ":") - .replace(/\s+/g, " ") - .trim(); -const normalizeRatioForPlatform = (platformValue: string, ratioValue: string, mode?: PlatformRatioModeKey) => { - const platformRatios = getPlatformRatioOptions(platformValue, mode); - if (platformRatios.includes(ratioValue)) return ratioValue; - const normalizedRatio = normalizeRatioToken(ratioValue); - const matchedRatio = platformRatios.find((option) => normalizeRatioToken(option).includes(normalizedRatio)); - return matchedRatio ?? getPlatformDefaultRatio(platformValue, mode); -}; -const quickSetRatioOptions = ["1:1", "3:4", "4:3", "9:16", "16:9"]; -const getQuickSetRatioValue = (value: string) => { - const normalizedValue = normalizeRatioToken(value); - if (quickSetRatioOptions.includes(normalizedValue)) return normalizedValue; - const sizeMatch = normalizedValue.match(/(\d+)\s*[×xX]\s*(\d+)/u); - if (sizeMatch) { - const width = Number(sizeMatch[1]); - const height = Number(sizeMatch[2]); - if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) { - const aspect = formatAspectRatio(width, height); - if (quickSetRatioOptions.includes(aspect)) return aspect; - } - } - const ratioMatch = normalizedValue.match(/(\d+)\s*[::]\s*(\d+)/u); - if (ratioMatch) { - const aspect = `${Number(ratioMatch[1])}:${Number(ratioMatch[2])}`; - if (quickSetRatioOptions.includes(aspect)) return aspect; - } - return quickSetRatioOptions[0]!; -}; -const formatRatioDisplayValue = (value: string) => { - const normalizedValue = normalizeRatioToken(value); - const sizeMatch = normalizedValue.match(/(\d+)\s*[×xX]\s*(\d+)\s*px?/u); - if (sizeMatch) { - const width = Number(sizeMatch[1]); - const height = Number(sizeMatch[2]); - return `${width}×${height}px\u00a0\u00a0\u00a0${formatAspectRatio(width, height)}`; - } - return normalizedValue - .replace("淘宝主图 / SKU 图 ", "淘宝主图 / SKU 图 ") - .replace("京东主图 / SKU 图 ", "京东主图 / SKU 图 ") - .replace("详情页宽", "详情页宽") - .replace("短视频", "短视频") - .replace("主图", "主图") - .replace("商品主图", "商品主图") - .replace("鍟嗗搧鍥?", "商品图") - .replace(/\s+:/g, ":") - .replace(/:\s+/g, ":"); -}; -const getRatioDisplayParts = (value: string) => { - const display = formatRatioDisplayValue(value).replace(/\u00a0/g, " ").replace(/\s+/g, " ").trim(); - const aspectMatch = display.match(/(\d+\s*[::]\s*\d+)(?!.*\d+\s*[::]\s*\d+)/u); - const aspect = aspectMatch?.[1]?.replace(/\s+/g, "") ?? "自适应"; - const size = aspectMatch ? display.replace(aspectMatch[0], "").trim() : display; - return { - size: size || "原图比例", - aspect, - }; -}; -/** Extract CSS aspect-ratio from a ratio string like "1000x1000px 1:1" -> "1 / 1" */ -const parseRatioToAspectCss = (ratioStr: string): string => { - const match = ratioStr.match(/(\d+)\D+(\d+)/u); - if (!match) return "1 / 1"; - return `${match[1]} / ${match[2]}`; -}; -const supportedImageApiRatios = ["1:1", "3:4", "4:3", "9:16", "16:9"] as const; -type SupportedImageApiRatio = typeof supportedImageApiRatios[number]; - -const toSupportedImageApiRatio = (width: number, height: number): SupportedImageApiRatio => { - if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return "1:1"; - let bestRatio: SupportedImageApiRatio = "1:1"; - let bestScore = Number.POSITIVE_INFINITY; - const target = Math.log(width / height); - for (const ratio of supportedImageApiRatios) { - const [left, right] = ratio.split(":").map(Number); - const score = Math.abs(target - Math.log(left / right)); - if (score < bestScore) { - bestRatio = ratio; - bestScore = score; - } - } - return bestRatio; -}; - -/** Normalize ratio display string ("1000×1000px 1:1") to an image API aspect ratio ("1:1"). */ -const normalizeRatioForApi = (ratioStr: string): string => { - const normalizedValue = normalizeRatioToken(ratioStr); - const explicitRatios = Array.from(normalizedValue.matchAll(/(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)/g)); - const explicitRatio = explicitRatios.at(-1); - if (explicitRatio) { - return toSupportedImageApiRatio(Number(explicitRatio[1]), Number(explicitRatio[2])); - } - - const sizeMatch = normalizedValue.match(/(\d+(?:\.\d+)?)\s*[×xX*]\s*(\d+(?:\.\d+)?)/u); - if (!sizeMatch) return "1:1"; - return toSupportedImageApiRatio(Number(sizeMatch[1]), Number(sizeMatch[2])); -}; -const greatestCommonDivisor = (left: number, right: number): number => { - let a = Math.abs(left); - let b = Math.abs(right); - while (b) { - [a, b] = [b, a % b]; - } - return a || 1; -}; -const formatAspectRatio = (width: number, height: number) => { - const divisor = greatestCommonDivisor(width, height); - return `${Math.round(width / divisor)}:${Math.round(height / divisor)}`; -}; -const formatUploadedImageRatio = (image?: CloneImageItem) => { - if (!image) return null; - const format = image.format ? `\u00a0\u00a0\u00a0${image.format}` : ""; - if (!image.width || !image.height) return `上传图片\u00a0\u00a0\u00a0原图比例${format}`; - return `上传图片 ${image.width}×${image.height}px\u00a0\u00a0\u00a0${formatAspectRatio(image.width, image.height)}${format}`; -}; -const defaultMarketLanguageOption = marketLanguageOptions[0]!; -const normalizeMarket = (value: string) => - marketLanguageOptions.some((option) => option.country === value) ? value : defaultMarketLanguageOption.country; -const normalizeLanguage = (value: string) => languageAliases[value] ?? value; -const uniqueLanguages = (languages: string[]) => Array.from(new Set(languages)); -const appendEnglish = (languages: string[]) => Array.from(new Set([...languages, "英文"])); -const getMarketLanguageOptions = (marketValue: string) => - appendEnglish((marketLanguageOptions.find((option) => option.country === marketValue) ?? defaultMarketLanguageOption).languages); -const getPlatformLanguageOptions = (platformValue: string, marketValue: string) => { - const marketLanguages = getMarketLanguageOptions(marketValue); - if (!isDomesticPlatform(platformValue)) return marketLanguages; - const localLanguages = marketLanguages.filter((item) => item !== "英文"); - return uniqueLanguages([...localLanguages, ...domesticPlatformLanguages, "英文"]); -}; -const getPlatformDefaultLanguage = (platformValue: string, marketValue: string) => - isDomesticPlatform(platformValue) ? "中文" : (getPlatformLanguageOptions(platformValue, marketValue)[0] ?? languageOptions[0] ?? "英文"); -const normalizeLanguageForPlatform = (platformValue: string, marketValue: string, languageValue: string) => { - const normalizedLanguage = normalizeLanguage(languageValue); - const platformLanguages = getPlatformLanguageOptions(platformValue, marketValue); - return platformLanguages.includes(normalizedLanguage) ? normalizedLanguage : getPlatformDefaultLanguage(platformValue, marketValue); -}; const productSetOutputOptions: Array<{ key: ProductSetOutputKey; label: string; desc: string; icon: ReactNode }> = [ { key: "set", label: "套图", desc: "主图/卖点/场景", icon: }, { key: "detail", label: "详情图", desc: "长图模块化生成", icon: }, @@ -1082,6 +415,64 @@ const commerceScenarioOutputMap: Record, retouch: "set", salesVideo: "video", }; + +const defaultCommerceIntentFallback: CommerceDefaultIntent = { kind: "image", scenario: "mainImage" }; + +const normalizeDefaultCommerceIntent = (value: unknown): CommerceDefaultIntent => { + if (!value || typeof value !== "object") return defaultCommerceIntentFallback; + const record = value as Record; + const kind = record.kind === "video" ? "video" : "image"; + const scenario = typeof record.scenario === "string" ? record.scenario : ""; + if (kind === "video" || scenario === "salesVideo") return { kind: "video", scenario: "salesVideo" }; + const imageScenarios: CommerceDefaultImageScenarioKey[] = ["poster", "mainImage", "scene", "festival", "model", "background", "retouch"]; + return imageScenarios.includes(scenario as CommerceDefaultImageScenarioKey) + ? { kind: "image", scenario: scenario as CommerceDefaultImageScenarioKey } + : defaultCommerceIntentFallback; +}; + +const commerceScenarioGenerationKind = (scenario: CommerceDefaultImageScenarioKey): "singleImage" | "imageEdit" => + scenario === "background" || scenario === "retouch" ? "imageEdit" : "singleImage"; + +const classifyDefaultCommerceIntent = async (input: { + prompt: string; + referenceCount: number; + ratio: string; + language: string; + platform: string; +}): Promise => { + const content = [ + "Classify this ecommerce creative request. Return only compact JSON.", + 'Schema: {"kind":"image"|"video","scenario":"poster"|"mainImage"|"scene"|"festival"|"model"|"background"|"retouch"|"salesVideo"}.', + "Use salesVideo for video, short-video, UGC, storyboard, or product-demo motion requests.", + "Use background for changing/replacing a product image background.", + "Use retouch for inpainting, cleanup, seamless edit, repair, or localized image modification.", + "Use model for try-on, human model, wearable, or mannequin requests.", + "Use poster for campaign posters, sale posters, banners, or marketing layouts.", + "Use scene for lifestyle/usage environment images.", + "Use festival for holiday/seasonal style images.", + "Use mainImage for product hero/main image requests or unclear image requests.", + `Prompt: ${input.prompt || "(empty)"}`, + `Reference image count: ${input.referenceCount}`, + `Platform: ${input.platform}`, + `Ratio: ${input.ratio}`, + `Language: ${input.language}`, + ].join("\n"); + + try { + const text = await aiGenerationClient.chatCompletion({ + messages: [ + { role: "system", content: "You are a strict ecommerce creative intent classifier. Respond with JSON only." }, + { role: "user", content }, + ], + stream: false, + temperature: 0, + }); + const jsonMatch = text.match(/\{[\s\S]*\}/); + return normalizeDefaultCommerceIntent(JSON.parse(jsonMatch?.[0] || text)); + } catch { + return defaultCommerceIntentFallback; + } +}; const commerceScenarioTemplates: CommerceScenarioTemplate[] = [ { id: "poster-campaign-clean", @@ -1418,11 +809,6 @@ const cloneSetCountOptions: Array<{ { key: "scene", title: "场景图", desc: "展示商品生活使用场景和人物搭配" }, ]; const cloneSetCountKeys = cloneSetCountOptions.map((option) => option.key); -const defaultCloneSetCounts: Record = { - selling: 3, - white: 1, - scene: 3, -}; const minCloneSetTotal = 1; const maxCloneSetTotal = 16; const maxCloneProductImages = 20; @@ -1430,11 +816,6 @@ const maxCloneReferenceImages = 20; const cloneVideoDurationMin = 5; const cloneVideoDurationMax = 45; const composerDurationOptions = [5, 10, 15]; -const defaultEcommercePlatform = "淘宝/天猫"; -const defaultProductSetOutput: ProductSetOutputKey = "set"; -const defaultCloneOutput: CloneOutputKey = "set"; -const cloneLatestSettingStorageKey = "omniai.clone-ai.latest-setting"; -const ecommerceHistoryStorageKey = "omniai.ecommerce.history.records"; const cloneVideoQualityOptions: Array<{ key: CloneVideoQualityKey; label: string; desc: string }> = [ { key: "standard", label: "标准", desc: "快速出片" }, { key: "high", label: "高清", desc: "推荐" }, @@ -1513,7 +894,6 @@ const detailModules = [ { id: "tips", title: "使用提示图", desc: "提醒操作与保养要点" }, ]; const defaultDetailModuleIds: string[] = []; -const defaultCloneDetailModuleIds = ["hero", "selling", "usage", "angle", "scene", "detail"]; const maxDetailModuleSelection = 6; const cloneDetailModules = detailModules; const detailAssets = ossAssets.ecommerce.detail; @@ -1685,188 +1065,24 @@ function notifyRejectedImages(files: File[]): File[] { return accepted; } -function isCloneSavedSetting(item: unknown): item is CloneSavedSetting { - const candidate = item as Partial; - return ( - typeof candidate.id === "string" && - typeof candidate.name === "string" && - typeof candidate.savedAt === "string" && - typeof candidate.output === "string" && - typeof candidate.platform === "string" && - typeof candidate.market === "string" && - typeof candidate.language === "string" && - typeof candidate.ratio === "string" && - typeof candidate.videoDurationSeconds === "number" - ); -} - -function readCloneLatestSetting() { - if (typeof window === "undefined") return null; - try { - const rawValue = window.localStorage.getItem(cloneLatestSettingStorageKey); - if (rawValue) { - const parsedValue: unknown = JSON.parse(rawValue); - if (isCloneSavedSetting(parsedValue)) return parsedValue; - } - } catch { - return null; - } - return null; -} - -function writeCloneLatestSetting(setting: CloneSavedSetting) { - if (typeof window === "undefined") return; - window.localStorage.setItem(cloneLatestSettingStorageKey, JSON.stringify(setting)); -} - -function isCloneImageItem(item: unknown): item is CloneImageItem { - const candidate = item as Partial; - return typeof candidate.id === "string" && typeof candidate.src === "string" && typeof candidate.name === "string"; -} - -function isCloneResult(item: unknown): item is CloneResult { - const candidate = item as Partial; - return typeof candidate.id === "string" && typeof candidate.src === "string" && typeof candidate.label === "string"; -} - -function isEcommerceHistoryRecord(item: unknown): item is EcommerceHistoryRecord { - const candidate = item as Partial; - return ( - typeof candidate.id === "string" && - typeof candidate.title === "string" && - typeof candidate.createdAt === "number" && - typeof candidate.output === "string" && - typeof candidate.platform === "string" && - typeof candidate.market === "string" && - typeof candidate.language === "string" && - typeof candidate.ratio === "string" && - typeof candidate.requirement === "string" && - Array.isArray(candidate.productImages) && - candidate.productImages.every(isCloneImageItem) && - Array.isArray(candidate.results) && - candidate.results.every(isCloneResult) - ); -} - -function removeFilePayloadFromImages(images: CloneImageItem[]): CloneImageItem[] { - return images.map(({ id, src, name, width, height, format, mimeType, ossKey }) => ({ - id, - src, - name, - width, - height, - format, - mimeType, - ossKey, - })); -} - -function getTurnResults(turn: EcommerceHistoryTurn): CloneResult[] { - if (turn.results?.length) return turn.results.filter((item) => item.src); - if (turn.output !== "set") return []; - return (turn.setResultImages ?? []) - .filter(Boolean) - .map((src, index) => ({ id: `${turn.id}-set-${index}`, src, label: `套图 ${index + 1}` })); -} - -function buildHistoryTurnFromRecord(record: EcommerceHistoryRecord): EcommerceHistoryTurn { - return { - id: `${record.id}-turn-initial`, - createdAt: record.createdAt, - status: record.status ?? "done", - errorMessage: record.status === "failed" ? record.errorMessage : undefined, - output: record.output, - platform: record.platform, - market: record.market, - language: record.language, - ratio: record.ratio, - requirement: record.requirement, - productImages: record.productImages ?? [], - results: record.results ?? [], - setResultImages: record.setResultImages ?? [], - setCounts: record.setCounts ?? defaultCloneSetCounts, - detailModules: record.detailModules ?? defaultCloneDetailModuleIds, - modelScenes: record.modelScenes ?? [], - referenceImages: record.referenceImages ?? [], - replicateLevel: record.replicateLevel ?? "high", - }; -} - -function normalizeEcommerceHistoryTurn(turn: EcommerceHistoryTurn, fallback: EcommerceHistoryRecord, index: number): EcommerceHistoryTurn { - const status = turn.status ?? fallback.status ?? "done"; - return { - id: typeof turn.id === "string" && turn.id ? turn.id : `${fallback.id}-turn-${index + 1}`, - createdAt: typeof turn.createdAt === "number" ? turn.createdAt : fallback.createdAt, - status, - errorMessage: status === "failed" ? turn.errorMessage ?? fallback.errorMessage : undefined, - output: turn.output ?? fallback.output, - platform: turn.platform ?? fallback.platform, - market: turn.market ?? fallback.market, - language: turn.language ?? fallback.language, - ratio: turn.ratio ?? fallback.ratio, - requirement: turn.requirement ?? fallback.requirement, - productImages: removeFilePayloadFromImages(Array.isArray(turn.productImages) ? turn.productImages : fallback.productImages), - results: Array.isArray(turn.results) ? turn.results.filter(isCloneResult) : [], - setResultImages: Array.isArray(turn.setResultImages) ? turn.setResultImages.filter(Boolean) : [], - setCounts: turn.setCounts ?? fallback.setCounts ?? defaultCloneSetCounts, - detailModules: turn.detailModules ?? fallback.detailModules ?? defaultCloneDetailModuleIds, - modelScenes: turn.modelScenes ?? fallback.modelScenes ?? [], - referenceImages: removeFilePayloadFromImages(Array.isArray(turn.referenceImages) ? turn.referenceImages : fallback.referenceImages ?? []), - replicateLevel: turn.replicateLevel ?? fallback.replicateLevel ?? "high", - }; -} - -function normalizeEcommerceHistoryRecord(record: EcommerceHistoryRecord): EcommerceHistoryRecord { - const status = record.status ?? "done"; - const baseRecord = { - ...record, - status, - errorMessage: status === "failed" ? record.errorMessage : undefined, - productImages: removeFilePayloadFromImages(record.productImages), - referenceImages: removeFilePayloadFromImages(record.referenceImages ?? []), - results: record.results ?? [], - setResultImages: record.setResultImages ?? [], - setCounts: record.setCounts ?? defaultCloneSetCounts, - detailModules: record.detailModules ?? defaultCloneDetailModuleIds, - modelScenes: record.modelScenes ?? [], - replicateLevel: record.replicateLevel ?? "high", - }; - const rawTurns = Array.isArray(record.turns) && record.turns.length - ? record.turns - : [buildHistoryTurnFromRecord(baseRecord)]; - const turns = rawTurns.map((turn, index) => normalizeEcommerceHistoryTurn(turn, baseRecord, index)); - return { - ...baseRecord, - turns, - }; -} - -function readEcommerceHistoryRecords() { - if (typeof window === "undefined") return []; - try { - const rawValue = window.localStorage.getItem(ecommerceHistoryStorageKey); - if (!rawValue) return []; - const parsedValue: unknown = JSON.parse(rawValue); - if (!Array.isArray(parsedValue)) return []; - return parsedValue - .filter(isEcommerceHistoryRecord) - .map(normalizeEcommerceHistoryRecord) - .sort((a, b) => b.createdAt - a.createdAt) - .slice(0, 30); - } catch { - return []; - } -} - -function writeEcommerceHistoryRecords(records: EcommerceHistoryRecord[]) { - if (typeof window === "undefined") return; - window.localStorage.setItem(ecommerceHistoryStorageKey, JSON.stringify(records.map(normalizeEcommerceHistoryRecord).slice(0, 30))); -} - function clampCloneVideoDuration(value: number) { return Math.min(cloneVideoDurationMax, Math.max(cloneVideoDurationMin, Math.round(value))); } +function mergeEcommerceHistoryRecords(...recordGroups: EcommerceHistoryRecord[][]): EcommerceHistoryRecord[] { + const recordsById = new Map(); + for (const records of recordGroups) { + for (const record of records) { + const normalized = normalizeEcommerceHistoryRecord(record); + const existing = recordsById.get(normalized.id); + if (!existing || normalized.createdAt >= existing.createdAt || normalized.turns?.length !== existing.turns?.length) { + recordsById.set(normalized.id, normalized); + } + } + } + return Array.from(recordsById.values()).sort((a, b) => b.createdAt - a.createdAt).slice(0, 30); +} + function ProductClonePage(_props: ProductClonePageProps = {}) { const setInputRef = useRef(null); const productInputRef = useRef(null); @@ -1927,7 +1143,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const [selectedProductSetPreview, setSelectedProductSetPreview] = useState(null); const [showHostingModal, setShowHostingModal] = useState(false); const [productImages, setProductImages] = useState([]); - const [activeQuickTool, setActiveQuickTool] = useState<"cutout" | "detail" | "watermark" | "image-edit" | "translate" | "hot" | "quick-set" | "copywriting" | null>(null); + const [activeQuickTool, setActiveQuickTool] = useState<"cutout" | "detail" | "watermark" | "image-edit" | "translate" | "hot" | "quick-set" | "copywriting" | "oneClickVideo" | null>(null); const [smartCutoutImage, setSmartCutoutImage] = useState(null); const [smartCutoutBatchImages, setSmartCutoutBatchImages] = useState([]); const [smartCutoutBackgroundColor, setSmartCutoutBackgroundColor] = useState("#ffffff"); @@ -1970,6 +1186,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const [videoHistoryVisible, setVideoHistoryVisible] = useState(false); const [isVideoWorkspaceVisible, setIsVideoWorkspaceVisible] = useState(false); const [videoPlanTrigger, setVideoPlanTrigger] = useState(0); + const [isDefaultIntentRouting, setIsDefaultIntentRouting] = useState(false); const [openCloneBasicSelect, setOpenCloneBasicSelect] = useState(null); const [openQuickSetSelect, setOpenQuickSetSelect] = useState(null); const [visibleQuickSetSelect, setVisibleQuickSetSelect] = useState(null); @@ -2411,6 +1628,27 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { useEffect(() => { writeEcommerceHistoryRecords(ecommerceHistoryRecords); }, [ecommerceHistoryRecords]); + + useEffect(() => { + if (!isAuthenticated) return; + let cancelled = false; + void listEcommerceGenerationHistory(30) + .then((serverRecords) => { + if (cancelled) return; + setEcommerceHistoryRecords((current) => { + const mergedRecords = mergeEcommerceHistoryRecords(serverRecords, current, readEcommerceHistoryRecords()); + writeEcommerceHistoryRecords(mergedRecords); + return mergedRecords; + }); + }) + .catch(() => { + // Local history remains available when the server list endpoint is offline. + }); + return () => { + cancelled = true; + }; + }, [isAuthenticated]); + const [customScene, setCustomScene] = useState(""); const [smartScene, setSmartScene] = useState(false); const [tryOnRatio, setTryOnRatio] = useState(tryOnRatioOptions[0]); @@ -4370,6 +3608,11 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { return urls; }; + const withStableSourceImage = (images: CloneImageItem[], sourceUrl?: string): CloneImageItem[] => { + if (!sourceUrl || !images.length) return images; + return images.map((image, index) => (index === 0 ? { ...image, src: sourceUrl } : image)); + }; + const setCountLabels: Record = { selling: { label: "卖点图", promptDesc: "selling-point infographic image highlighting core product advantages and detail close-ups" }, white: { label: "白底图", promptDesc: "clean white-background product photo showing the item from its best angle, studio lighting, no props" }, @@ -4449,6 +3692,125 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { return parts.join(" "); }; + const buildCommerceScenarioImagePrompt = ( + scenario: CommerceDefaultImageScenarioKey, + userText: string, + pPlatform: string, + pRatio: string, + pLanguage: string, + pMarket: string, + ): string => { + const parts: string[] = []; + const scenarioPrompts: Record = { + poster: "Generate one ecommerce campaign poster image with clear product focus, promotional hierarchy, and polished marketing layout.", + mainImage: "Generate one high-conversion ecommerce product main image. Keep the product accurate, clear, and platform-ready.", + scene: "Generate one realistic ecommerce lifestyle scene image. Preserve the product appearance and place it in a suitable usage environment.", + festival: "Generate one ecommerce product image with a tasteful holiday or seasonal marketing style.", + model: "Generate one ecommerce model or try-on image that naturally presents the product on or near a suitable model.", + background: "Replace or rebuild the product image background. Preserve the product exactly and use the user's prompt or extra reference image as background guidance.", + retouch: "Perform a seamless ecommerce image edit. Preserve the product identity while applying the user's requested local cleanup or refinement.", + }; + parts.push(scenarioPrompts[scenario]); + parts.push(`Platform: ${pPlatform}. Aspect ratio: ${pRatio}. Language/copy: ${pLanguage}. Market: ${pMarket}.`); + parts.push("Output a single image only."); + if (userText.trim()) parts.push(`User request: ${userText.trim()}`); + return parts.join(" "); + }; + + const generateCommerceScenarioImage = async ( + scenario: CommerceDefaultImageScenarioKey, + images: CloneImageItem[], + userText: string, + pPlatform: string, + pRatio: string, + pLanguage: string, + pMarket: string, + statusFn: (status: "generating" | "done" | "idle" | "failed") => void, + resultFn: (results: CloneResult[], sourceUrl?: string) => void, + ): Promise => { + statusFn("generating"); + try { + const uploadedUrls = await uploadCloneImages(images); + if (!uploadedUrls.length) { + statusFn("idle"); + return; + } + if (imageAbortRef.current.current) { + statusFn("idle"); + return; + } + + const prompt = buildCommerceScenarioImagePrompt(scenario, userText, pPlatform, pRatio, pLanguage, pMarket); + const stamp = Date.now(); + const label = commerceScenarioOptions.find((option) => option.key === scenario)?.label || selectedCloneOutput.label; + setGenerationProgress(0); + + const imageTask = scenario === "background" || scenario === "retouch" + ? await aiGenerationClient.createImageEditTask({ + imageUrl: uploadedUrls[0]!, + function: scenario === "background" ? "background-replace" : "retouch", + prompt, + ratio: normalizeRatioForApi(pRatio), + referenceUrls: uploadedUrls.slice(1), + }) + : await aiGenerationClient.createImageTask({ + prompt, + ratio: normalizeRatioForApi(pRatio), + quality: pRatio.includes("720") ? "720P" : "1080P", + gridMode: "single", + referenceUrls: uploadedUrls, + }); + const { taskId } = imageTask; + const storeId = imageGen.submitTask({ title: label, type: "image", status: "running", progress: 5, prompt, sourceView: "ecommerce", taskId }); + + const immediateResultUrl = (imageTask as { resultUrl?: string | null }).resultUrl; + let resultUrl: string | null = immediateResultUrl ?? null; + if (!resultUrl) { + trackEcommerceTask(taskId); + try { + resultUrl = await waitForTask(taskId, { + kind: "image", + abortRef: imageAbortRef.current, + onProgress: (event) => { + const sub = Math.max(0, Math.min(100, Number(event.progress) || 0)); + setGenerationProgress(Math.round(Math.min(99, sub))); + }, + }); + } finally { + untrackEcommerceTask(taskId); + } + } else { + setGenerationProgress(100); + } + + if (imageAbortRef.current.current) { + statusFn("idle"); + return; + } + + if (resultUrl) { + const persistedUrl = await persistGeneratedImageUrl(resultUrl, ecommerceOssScopes.cloneResult(scenario), `ecommerce-${scenario}`); + resultFn([{ id: `scenario-${scenario}-${stamp}`, src: persistedUrl, label }], uploadedUrls[0]); + statusFn("done"); + imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl }); + } else { + statusFn("failed"); + imageGen.updateTask(storeId, { status: "failed", error: "No image result returned" }); + } + } catch (err) { + if (imageAbortRef.current.current) { + statusFn("idle"); + return; + } + if (err instanceof ServerRequestError && err.status === 402) { + toast.error("余额不足,请充值后继续"); + } else { + toast.error(err instanceof Error ? err.message : "生成失败"); + } + statusFn("failed"); + } + }; + const generateSetImages = async ( images: CloneImageItem[], counts: Record, @@ -4458,7 +3820,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { pLanguage: string, pMarket: string, setStatusFn: (status: "generating" | "done" | "idle" | "failed") => void, - setResultFn: (urls: string[]) => void, + setResultFn: (urls: string[], sourceUrl?: string) => void, ): Promise => { setStatusFn("generating"); try { @@ -4486,31 +3848,35 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const subPrompt = buildSetSubPrompt(countKey, i, count, pPlatform, pRatio, pLanguage, pMarket); const fullPrompt = userText.trim() ? `${subPrompt} Additional user requirements: ${userText.trim()}` : subPrompt; - const { taskId } = await aiGenerationClient.createImageTask({ + const imageTask = await aiGenerationClient.createImageTask({ prompt: fullPrompt, ratio: normalizeRatioForApi(pRatio), quality: pRatio.includes("720") ? "720P" : "1080P", gridMode: "single", referenceUrls, }); - trackEcommerceTask(taskId); + const { taskId } = imageTask; const storeId = imageGen.submitTask({ title: `${setCountLabels[countKey].label} ${i + 1}`, type: "image", status: "running", progress: 5, prompt: fullPrompt, sourceView: "ecommerce", taskId }); - let resultUrl: string | null = null; - try { - resultUrl = await waitForTask(taskId, { - kind: "image", - abortRef: imageAbortRef.current, - onProgress: (event) => { - // 整体进度 = (已完成张数 + 当前张子进度) / 总张数。 - const sub = Math.max(0, Math.min(100, Number(event.progress) || 0)); - const overall = ((completedCount + sub / 100) / totalCount) * 100; - setGenerationProgress(Math.round(Math.min(99, overall))); - }, - }); - } finally { - untrackEcommerceTask(taskId); + let resultUrl: string | null = imageTask.resultUrl ?? null; + if (!resultUrl) { + trackEcommerceTask(taskId); + try { + resultUrl = await waitForTask(taskId, { + kind: "image", + abortRef: imageAbortRef.current, + onProgress: (event) => { + const sub = Math.max(0, Math.min(100, Number(event.progress) || 0)); + const overall = ((completedCount + sub / 100) / totalCount) * 100; + setGenerationProgress(Math.round(Math.min(99, overall))); + }, + }); + } finally { + untrackEcommerceTask(taskId); + } + } else { + setGenerationProgress(Math.round(Math.min(99, ((completedCount + 1) / totalCount) * 100))); } if (imageAbortRef.current.current) break; @@ -4532,7 +3898,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { setStatusFn("idle"); return; } - setResultFn(generatedUrls); + setResultFn(generatedUrls, referenceUrls[0]); setStatusFn(generatedUrls.some(Boolean) ? "done" : "failed"); } catch (err) { if (imageAbortRef.current.current) { @@ -4560,7 +3926,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { pMarket: string, tryOnOptions?: EcommerceImagePromptOptions, statusFn?: (status: "generating" | "done" | "idle" | "failed") => void, - resultFn?: (results: CloneResult[]) => void, + resultFn?: (results: CloneResult[], sourceUrl?: string) => void, ): Promise => { statusFn?.("generating"); try { @@ -4578,29 +3944,35 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const stamp = Date.now(); setGenerationProgress(0); - const { taskId } = await aiGenerationClient.createImageTask({ + const imageTask = await aiGenerationClient.createImageTask({ prompt, ratio: normalizeRatioForApi(pRatio), quality: pRatio.includes("720") ? "720P" : "1080P", gridMode: "single", referenceUrls, }); - trackEcommerceTask(taskId); + const { taskId } = imageTask; - const storeId = imageGen.submitTask({ title: `电商${outputKey}图`, type: "image", status: "running", progress: 5, prompt, sourceView: "ecommerce", taskId }); + const outputLabel = cloneOutputOptions.find((option) => option.key === outputKey)?.label || selectedCloneOutput.label; + const storeId = imageGen.submitTask({ title: outputLabel, type: "image", status: "running", progress: 5, prompt, sourceView: "ecommerce", taskId }); - let resultUrl: string | null = null; - try { - resultUrl = await waitForTask(taskId, { - kind: "image", - abortRef: imageAbortRef.current, - onProgress: (event) => { - const sub = Math.max(0, Math.min(100, Number(event.progress) || 0)); - setGenerationProgress(Math.round(Math.min(99, sub))); - }, - }); - } finally { - untrackEcommerceTask(taskId); + let resultUrl: string | null = imageTask.resultUrl ?? null; + if (!resultUrl) { + trackEcommerceTask(taskId); + try { + resultUrl = await waitForTask(taskId, { + kind: "image", + abortRef: imageAbortRef.current, + onProgress: (event) => { + const sub = Math.max(0, Math.min(100, Number(event.progress) || 0)); + setGenerationProgress(Math.round(Math.min(99, sub))); + }, + }); + } finally { + untrackEcommerceTask(taskId); + } + } else { + setGenerationProgress(100); } if (imageAbortRef.current.current) { @@ -4610,7 +3982,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { if (resultUrl) { const persistedUrl = await persistGeneratedImageUrl(resultUrl, ecommerceOssScopes.cloneResult(outputKey), `ecommerce-${outputKey}`); - resultFn?.([{ id: `ecommerce-${stamp}`, src: persistedUrl, label: selectedCloneOutput.label }]); + resultFn?.([{ id: `ecommerce-${stamp}`, src: persistedUrl, label: outputLabel }], referenceUrls[0]); statusFn?.("done"); imageGen.updateTask(storeId, { status: "completed", progress: 100, resultUrl: persistedUrl }); } else { @@ -4633,7 +4005,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { } }; - const handleGenerate = () => { + const handleGenerate = (defaultIntent?: CommerceDefaultIntent) => { if (!canGenerate) return; if ((appUsage?.balanceCents ?? 0) <= 0) { @@ -4641,7 +4013,14 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { return; } - if (cloneOutput === "set" && cloneSetTotal > 5) { + const explicitImageScenario = + activeCommerceScenario && activeCommerceScenario !== "popular" && activeCommerceScenario !== "salesVideo" + ? activeCommerceScenario + : null; + const routedScenario = defaultIntent?.kind === "image" ? defaultIntent.scenario : explicitImageScenario; + const effectiveOutput = routedScenario ? commerceScenarioOutputMap[routedScenario] : cloneOutput; + const shouldConfirmSetCount = !defaultIntent && activeCommerceScenario !== "popular" && effectiveOutput === "set" && cloneSetTotal > 5; + if (shouldConfirmSetCount) { if (!window.confirm("将生成 " + String(cloneSetTotal) + " 张图片,可能消耗较多积分,是否继续?")) return; } @@ -4658,7 +4037,71 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { setPreviewZoom(1); setPreviewOffset({ x: 0, y: 0 }); previewOffsetRef.current = { x: 0, y: 0 }; - if (cloneOutput === "set") { + if (defaultIntent?.kind === "video") { + handleStartVideoPlan(); + return; + } + + if (routedScenario) { + const routedModeLabel = commerceScenarioOptions.find((option) => option.key === routedScenario)?.label || selectedCloneOutput.label; + const routedSettingLabel = commerceScenarioGenerationKind(routedScenario) === "imageEdit" ? "图片编辑 1张" : "单图 1张"; + const routedGenerationKind = commerceScenarioGenerationKind(routedScenario); + void generateCommerceScenarioImage( + routedScenario, productImages, requirement, + platform, ratio, language, market, + (s) => { + setStatus(s as ProductCloneStatus); + if (s === "generating") { + updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ + ...turn, + output: effectiveOutput, + modeLabel: routedModeLabel, + settingLabel: routedSettingLabel, + generationKind: routedGenerationKind, + status: "generating", + errorMessage: undefined, + })); + } else if (s === "failed") { + updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ + ...turn, + output: effectiveOutput, + modeLabel: routedModeLabel, + settingLabel: routedSettingLabel, + generationKind: routedGenerationKind, + status: "failed", + errorMessage: "生成失败,请检查网络或参数后重试。", + })); + } + }, + (newResults, sourceUrl) => { + const validResults = newResults.filter((item) => item.src); + const turnProductImages = withStableSourceImage(productImages, sourceUrl); + setResults(validResults); + updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ + ...turn, + output: effectiveOutput, + modeLabel: routedModeLabel, + settingLabel: routedSettingLabel, + generationKind: routedGenerationKind, + status: validResults.length ? "done" : "failed", + errorMessage: validResults.length ? undefined : newResults[0]?.label || "生成未返回结果", + productImages: turnProductImages, + results: validResults, + setResultImages: [], + })); + if (validResults.length && validResults[0].src) { + upsertCanvasNode({ + id: pendingTurnId, + mode: routedScenario, + sourceImage: sourceUrl || productImages[0]?.src, + results: validResults, + createdAt: Date.now(), + }); + } + }, + ); + lastFailedActionRef.current = () => handleGenerate(defaultIntent); + } else if (cloneOutput === "set") { void generateSetImages( productImages, cloneSetCounts, requirement, platform, ratio, language, market, @@ -4670,14 +4113,17 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: "failed", errorMessage: "生成失败,请检查网络或参数后重试。" })); } }, - (urls) => { + (urls, sourceUrl) => { setProductSetResultImages(urls); const validUrls = urls.filter(Boolean); + const stableSourceUrl = sourceUrl || (productImages[0]?.src?.startsWith("blob:") ? undefined : productImages[0]?.src); + const turnProductImages = withStableSourceImage(productImages, stableSourceUrl); const resultCards = validUrls.map((src, i) => ({ id: `set-${Date.now()}-${i}`, src, label: `套图 ${i + 1}` })); updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: validUrls.length ? "done" : "failed", errorMessage: validUrls.length ? undefined : "生成未返回结果", + productImages: turnProductImages, setResultImages: validUrls, results: resultCards, })); @@ -4685,7 +4131,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { upsertCanvasNode({ id: pendingTurnId, mode: "set", - sourceImage: productImages[0]?.src, + sourceImage: stableSourceUrl || productImages[0]?.src, results: resultCards, createdAt: Date.now(), }); @@ -4719,13 +4165,15 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: "failed", errorMessage: "生成失败,请检查网络或参数后重试。" })); } }, - (newResults: CloneResult[]) => { + (newResults: CloneResult[], sourceUrl?: string) => { const validResults = newResults.filter((item) => item.src); + const turnProductImages = withStableSourceImage(productImages, sourceUrl); setResults(validResults); updateLocalEcommerceHistoryTurn(pendingRecordId, pendingTurnId, (turn) => ({ ...turn, status: validResults.length ? "done" : "failed", errorMessage: validResults.length ? undefined : newResults[0]?.label || "生成未返回结果", + productImages: turnProductImages, results: validResults, setResultImages: [], })); @@ -4733,7 +4181,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { upsertCanvasNode({ id: pendingTurnId, mode: cloneOutput, - sourceImage: productImages[0]?.src, + sourceImage: sourceUrl || productImages[0]?.src, results: validResults, createdAt: Date.now(), }); @@ -5013,7 +4461,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { productImages, cloneSetCounts, quickSetRequirement, platform, ratio, language, market, (s) => { - setQuickSetStatus(s as ProductCloneStatus); + setQuickSetStatus(s as "idle" | "generating" | "done" | "failed"); if (s === "done") { stopQuickSetProgress(); setQuickSetProgress(100); @@ -5134,6 +4582,26 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { setComposerMenu(null); }; + const openOneClickVideoPage = () => { + clearSmartCutoutTransition(); + setActiveQuickTool("oneClickVideo"); + setComposerMenu(null); + setIsCloneSettingsCollapsed(false); + setIsQuickPanelCollapsed(false); + }; + + const closeOneClickVideoPage = () => { + setActiveQuickTool(null); + setComposerMenu(null); + }; + + const handleOneClickVideoPlatformChange = (nextPlatform: string) => { + const normalizedPlatform = normalizePlatform(nextPlatform); + setPlatform(normalizedPlatform); + setRatio((current) => normalizeRatioForPlatform(normalizedPlatform, current, "video")); + setLanguage(getPlatformDefaultLanguage(normalizedPlatform, market)); + }; + const resetTask = () => { setSetImages([]); setProductSetRequirement(""); @@ -5199,6 +4667,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { const isHotCloneTool = isCloneTool && activeQuickTool === "hot"; const isQuickSetTool = isCloneTool && activeQuickTool === "quick-set"; const isCopywritingTool = isCloneTool && activeQuickTool === "copywriting"; + const isOneClickVideoTool = isCloneTool && activeQuickTool === "oneClickVideo"; const pageLabel = isSetTool ? "商品套图" : isDetail ? "A+/详情页" : isTryOn ? "AI服饰穿戴" : activeToolMeta?.label || "商品工具"; const setPrimaryLabel = setImages.length === 0 @@ -5299,6 +4768,42 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { referenceImageCount: record.referenceImages.length, turnCount: record.turns?.length ?? 1, latestTurnId: record.turns?.[record.turns.length - 1]?.id, + modeLabel: record.modeLabel, + settingLabel: record.settingLabel, + generationKind: record.generationKind, + referenceImages: record.referenceImages.map(({ id, src, name, width, height, format, mimeType, ossKey }) => ({ + id, + src, + name, + width, + height, + format, + mimeType, + ossKey, + })), + turns: (record.turns?.length ? record.turns : [buildHistoryTurnFromRecord(record)]).map((turn) => ({ + ...turn, + productImages: turn.productImages.map(({ id, src, name, width, height, format, mimeType, ossKey }) => ({ + id, + src, + name, + width, + height, + format, + mimeType, + ossKey, + })), + referenceImages: turn.referenceImages.map(({ id, src, name, width, height, format, mimeType, ossKey }) => ({ + id, + src, + name, + width, + height, + format, + mimeType, + ossKey, + })), + })), }, createdAt: new Date(record.createdAt).toISOString(), }); @@ -5333,6 +4838,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { createdAt, status: turnStatus, output: cloneOutput, + modeLabel: undefined, + settingLabel: undefined, + generationKind: cloneOutput === "video" ? "video" : cloneOutput === "set" ? "imageSet" : "singleImage", platform, market, language, @@ -5353,6 +4861,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { status: turn.status, errorMessage: turn.status === "failed" ? turn.errorMessage : undefined, output: turn.output, + modeLabel: turn.modeLabel, + settingLabel: turn.settingLabel, + generationKind: turn.generationKind, platform: turn.platform, market: turn.market, language: turn.language, @@ -5398,6 +4909,9 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { createdAt, status: turn.status, output: turn.output, + modeLabel: turn.modeLabel, + settingLabel: turn.settingLabel, + generationKind: turn.generationKind, platform: turn.platform, market: turn.market, language: turn.language, @@ -5507,7 +5021,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { items.push({ id: turn.id, mode: turn.output, - sourceImage: turn.productImages[0]?.src, + sourceImage: turn.productImages[0]?.src?.startsWith("blob:") ? undefined : turn.productImages[0]?.src, results: turnResults, createdAt: turn.createdAt, x: index * 420, @@ -5561,7 +5075,35 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { window.setTimeout(() => setHistoryRefreshMessage(""), 3000); }; + const refreshEcommerceHistoryFromServer = async () => { + if (historyRefreshLockRef.current) return; + historyRefreshLockRef.current = true; + setIsHistoryRefreshing(true); + setHistoryRefreshMessage("Refreshing..."); + setHistoryRefreshStamp(Date.now()); + try { + const serverRecords = isAuthenticated ? await listEcommerceGenerationHistory(30) : []; + const mergedRecords = mergeEcommerceHistoryRecords(serverRecords, ecommerceHistoryRecords, readEcommerceHistoryRecords()); + writeEcommerceHistoryRecords(mergedRecords); + setHistoryRefreshTick((tick) => tick + 1); + setEcommerceHistoryRecords(mergedRecords); + setHistoryRefreshMessage(mergedRecords.length ? "Synced " + String(mergedRecords.length) + " records" : "No history records"); + setHistoryRefreshStamp(Date.now()); + } catch { + const mergedRecords = mergeEcommerceHistoryRecords(ecommerceHistoryRecords, readEcommerceHistoryRecords()); + writeEcommerceHistoryRecords(mergedRecords); + setHistoryRefreshTick((tick) => tick + 1); + setEcommerceHistoryRecords(mergedRecords); + setHistoryRefreshMessage(mergedRecords.length ? "Loaded " + String(mergedRecords.length) + " local records" : "Server history unavailable"); + setHistoryRefreshStamp(Date.now()); + } finally { + setIsHistoryRefreshing(false); + historyRefreshLockRef.current = false; + } + + window.setTimeout(() => setHistoryRefreshMessage(""), 3000); + }; const deleteHistoryRecord = (recordId: string, event: ReactMouseEvent) => { event.stopPropagation(); const record = ecommerceHistoryRecords.find((r) => r.id === recordId); @@ -6197,7 +5739,8 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { }; const canPlanVideo = productImages.length > 0 || requirement.trim().length > 0; - const commandGenerateDisabled = cloneOutput === "video" ? false : !canGenerate; + const isDefaultCommandRouting = activeCommerceScenario === null || activeCommerceScenario === "popular"; + const commandGenerateDisabled = isDefaultIntentRouting || (isDefaultCommandRouting ? !canPlanVideo : cloneOutput === "video" ? false : !canGenerate); function handleStartVideoPlan() { if (!canPlanVideo) { @@ -6212,11 +5755,36 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { setVideoPlanTrigger((value) => value + 1); } - const handleCommandGenerate = () => { + const handleCommandGenerate = async () => { if (cloneOutput === "video") { handleStartVideoPlan(); return; } + if (isDefaultCommandRouting) { + if (!canPlanVideo) return; + setIsDefaultIntentRouting(true); + try { + const intent = await classifyDefaultCommerceIntent({ + prompt: requirement, + referenceCount: productImages.length, + ratio, + language, + platform, + }); + if (intent.kind === "video") { + handleStartVideoPlan(); + return; + } + if (!canGenerate) { + toast.info("请先上传商品图"); + return; + } + handleGenerate(intent); + } finally { + setIsDefaultIntentRouting(false); + } + return; + } handleGenerate(); }; @@ -6542,25 +6110,35 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { } }} /> - {node.sourceImage ? ( -
- - -
- ) : null} +
+ + +
@@ -6888,6 +6466,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { { label: "图片翻译", tone: "translate", icon: , onClick: openImageTranslatePage }, { label: "商品套图", tone: "product", icon: , onClick: openQuickSetPage }, { label: "一键文案", tone: "copywriting", icon: , onClick: openCopywritingPage }, + { label: "一键视频", tone: "video", icon: , onClick: openOneClickVideoPage }, { label: "更多功能", tone: "more", icon: , disabled: true }, ].map((item) => ( - - -

上传商品素材,快速清理画面中的水印、文字和瑕疵。

-
-
- 上传素材 - {watermarkImage ? "已上传" : "待上传"} -
-
watermarkInputRef.current?.click()} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - watermarkInputRef.current?.click(); - } - }} - onDragEnter={(event) => { - event.preventDefault(); - setIsWatermarkDragging(true); - }} - onDragOver={(event) => event.preventDefault()} - onDragLeave={() => setIsWatermarkDragging(false)} - onDrop={handleWatermarkDrop} - > - {watermarkImage ? ( - <> - -
- {watermarkImage.name} -
-
- {watermarkImage.name} - {watermarkImage.format || "PNG / JPG / WebP"} -
- - ) : ( - <> - - 上传含水印图片 - 支持 PNG / JPG / WebP,拖拽或点击上传 - - )} -
-
- { - if (event.key === "Enter") void handleWatermarkUrlImport(); - }} - /> - -
-
- -
- 处理说明 -

优先保留商品主体、材质和边缘细节,适合电商主图、详情图和社媒素材清理。

-
- - - - -
- {!watermarkImage ? ( -
watermarkInputRef.current?.click()} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - watermarkInputRef.current?.click(); - } - }} - onDragEnter={(event) => { - event.preventDefault(); - setIsWatermarkDragging(true); - }} - onDragOver={(event) => event.preventDefault()} - onDragLeave={() => setIsWatermarkDragging(false)} - onDrop={handleWatermarkDrop} - > - - 点击或拖拽上传图片 - 支持 PNG / JPG / WebP,上传含水印图片后点击开始去水印 -
- ) : ( -
-
- 原图 - 原图 -
- -
- 去水印结果 - {watermarkStatus === "processing" ? ( -
- - 正在去水印 - AI 正在清理图片中的水印和文字 -
-
-
- {Math.round(watermarkProgress)}% -
- ) : watermarkStatus === "done" && watermarkResultUrl ? ( - <> - 去水印结果 - - - ) : watermarkStatus === "failed" ? ( -
- - 去水印失败 - 请检查网络或重试,如余额不足请先充值 -
- ) : ( -
- - 等待处理 - 点击开始去水印后显示结果 -
- )} -
- - -
-
-
- )} -
- + ); const translateLanguageOptions = [ @@ -8731,6 +8149,42 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { ); + const oneClickVideoPreview = ( +
+ setVideoHistoryVisible(true)} + /> +
+ ); + const activePreview = isSetTool ? setPreview : isDetail @@ -8766,9 +8220,11 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { ) : isCopywritingTool ? copywritingPreview - : clonePreview + : isOneClickVideoTool + ? oneClickVideoPreview + : clonePreview : placeholderPreview; - const isMainCloneWorkspace = isCloneTool && !isSmartCutoutTool && !isQuickDetailTool && !isWatermarkTool && !isTranslateTool && !isImageEditTool && !isQuickSetTool && !isCopywritingTool; + const isMainCloneWorkspace = isCloneTool && !isSmartCutoutTool && !isQuickDetailTool && !isWatermarkTool && !isTranslateTool && !isImageEditTool && !isQuickSetTool && !isCopywritingTool && !isOneClickVideoTool; const isRecordDetailWorkspace = isMainCloneWorkspace && Boolean(activeHistoryRecordId); const currentResultCount = canvasNodes.reduce((count, node) => count + node.results.length, 0); const activeHistoryRecord = activeHistoryRecordId ? ecommerceHistoryRecords.find((record) => record.id === activeHistoryRecordId) : null; @@ -8778,6 +8234,10 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { : [buildHistoryTurnFromRecord(activeHistoryRecord)] : []; const getHistoryTurnSettingLabel = (turn: EcommerceHistoryTurn) => { + if (turn.settingLabel) return turn.settingLabel; + if (turn.output === "set" && turn.results?.length && !turn.setResultImages?.length) { + return `单图 ${turn.results.length}张`; + } if (turn.output === "set") { const total = cloneSetCountKeys.reduce((sum, key) => sum + (turn.setCounts?.[key] ?? 0), 0); return `套图 ${total || 1}张`; @@ -8804,7 +8264,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { return (
@@ -8862,7 +8322,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
{activeConversationTurns.map((turn, index) => { const turnResults = getTurnResults(turn); - const outputLabel = cloneOutputOptions.find((option) => option.key === turn.output)?.label || selectedCloneOutput.label; + const outputLabel = turn.modeLabel || cloneOutputOptions.find((option) => option.key === turn.output)?.label || selectedCloneOutput.label; const turnMeta = [ { label: "平台", value: turn.platform }, { label: "语种", value: turn.language }, @@ -8873,7 +8333,7 @@ function ProductClonePage(_props: ProductClonePageProps = {}) { return (
0 ? " clone-ai-chat-message--followup" : ""}`}> - {index === 0 ? "需求" : `继续生成 ${index + 1}`} + {index === 0 ? "需求" : `继续生成 ${index + 1} · ${outputLabel}`}

{turn.requirement?.trim() || "上传商品素材,描述你想生成的商品图、详情图、模特图或短视频。"}

{turnMeta.map((item) => ( @@ -8946,160 +8406,35 @@ function ProductClonePage(_props: ProductClonePageProps = {}) {
- {isCloneTool && !isCommandHistoryCollapsed ? ( -
setIsCommandHistoryCollapsed(true)} - /> - ) : null} + setIsCommandHistoryCollapsed((current) => !current)} + onCollapse={() => setIsCommandHistoryCollapsed(true)} + onNewConversation={handleNewEcommerceConversation} + onRefresh={refreshEcommerceHistoryFromServer} + onOpenRecord={openEcommerceHistoryRecord} + onDeleteRecord={deleteHistoryRecord} + /> - + setSelectedProductSetPreview(null)} + onDownload={(preview) => { + void handleDownloadCanvasResult(preview); + }} + onRemove={removeSelectedProductSetPreview} + /> - {selectedProductSetPreview && typeof document !== "undefined" ? createPortal(( -
setSelectedProductSetPreview(null)}> -
event.stopPropagation()} - > - - {selectedProductSetPreview.label} -
- {selectedProductSetPreview.label} -
- - {selectedProductSetPreview.removable ? ( - - ) : null} -
-
-
-
- ), document.body) : null} - - {showHostingModal ? ( -
-
- 托管模式 -
- -

- 批量托管上线啦! - 批量6折 -

- 睡一觉,图就做好了! -
    -
  • - 批量生产 - 支持多任务并行生成,效率直线提升。 -
  • -
  • - 成本立省40% - 调度夜间闲置算力,享受专属离线点数折扣。 -
  • -
  • - AI智能提取 - 自动识别图片卖点,生成高转化销售卖点。 -
  • -
- -
-
-
- ) : null} + setShowHostingModal(false)} /> { await deleteGenerationRecordByClientId(clientRecordId); } + +const ecommerceHistoryStatuses = new Set(["generating", "done", "failed"]); +const cloneOutputs = new Set(["set", "detail", "model", "video", "hot"]); +const generationKinds = new Set(["singleImage", "imageEdit", "imageSet", "video"]); +const replicateLevels = new Set(["style", "high"]); + +function stringValue(value: unknown, fallback = ""): string { + return typeof value === "string" && value.trim() ? value : fallback; +} + +function numberValue(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function objectValue(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +function stringArrayValue(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && Boolean(item.trim())) : []; +} + +function normalizeOutput(value: unknown): CloneOutputKey { + if (value === "short-video") return "video"; + return cloneOutputs.has(value as CloneOutputKey) ? value as CloneOutputKey : defaultCloneOutput; +} + +function normalizeStatus(value: unknown): EcommerceHistoryStatus { + if (value === "completed") return "done"; + return ecommerceHistoryStatuses.has(value as EcommerceHistoryStatus) ? value as EcommerceHistoryStatus : "done"; +} + +function normalizeGenerationKind(value: unknown, output: CloneOutputKey): EcommerceHistoryTurn["generationKind"] { + if (generationKinds.has(value as EcommerceHistoryTurn["generationKind"])) return value as EcommerceHistoryTurn["generationKind"]; + if (output === "video") return "video"; + if (output === "set") return "imageSet"; + return "singleImage"; +} + +function normalizeReplicateLevel(value: unknown): CloneReplicateLevelKey { + return replicateLevels.has(value as CloneReplicateLevelKey) ? value as CloneReplicateLevelKey : "high"; +} + +function normalizeSetCounts(value: unknown): Record { + const counts = objectValue(value); + return { + selling: numberValue(counts.selling, defaultCloneSetCounts.selling), + white: numberValue(counts.white, defaultCloneSetCounts.white), + scene: numberValue(counts.scene, defaultCloneSetCounts.scene), + }; +} + +function timestampValue(value: unknown, fallback: number): number { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const parsed = new Date(value).getTime(); + if (Number.isFinite(parsed)) return parsed; + } + return fallback; +} + +function imageFromAsset(asset: GenerationRecordAsset, index: number): CloneImageItem { + return { + id: stringValue(asset.taskId, `server-source-${index + 1}`), + src: asset.url, + name: stringValue(asset.label, `source-${index + 1}`), + ossKey: asset.ossKey || undefined, + }; +} + +function resultFromAsset(asset: GenerationRecordAsset, index: number): CloneResult { + return { + id: stringValue(asset.taskId, `server-result-${index + 1}`), + src: asset.url, + label: stringValue(asset.label, `result-${index + 1}`), + type: asset.mediaType === "video" ? "video" : "image", + }; +} + +function normalizeHistoryImages(value: unknown, fallback: CloneImageItem[] = []): CloneImageItem[] { + if (!Array.isArray(value)) return fallback; + return value + .map((item, index): CloneImageItem | null => { + const record = objectValue(item); + const src = stringValue(record.src); + if (!src) return null; + return { + id: stringValue(record.id, `server-image-${index + 1}`), + src, + name: stringValue(record.name, `image-${index + 1}`), + width: typeof record.width === "number" ? record.width : undefined, + height: typeof record.height === "number" ? record.height : undefined, + format: typeof record.format === "string" ? record.format : undefined, + mimeType: typeof record.mimeType === "string" ? record.mimeType : undefined, + ossKey: typeof record.ossKey === "string" ? record.ossKey : undefined, + }; + }) + .filter((item): item is CloneImageItem => Boolean(item)); +} + +function normalizeHistoryResults(value: unknown, fallback: CloneResult[] = []): CloneResult[] { + if (!Array.isArray(value)) return fallback; + return value + .map((item, index): CloneResult | null => { + const record = objectValue(item); + const src = stringValue(record.src); + if (!src) return null; + return { + id: stringValue(record.id, `server-result-${index + 1}`), + src, + label: stringValue(record.label, `result-${index + 1}`), + type: record.type === "video" ? "video" : "image", + }; + }) + .filter((item): item is CloneResult => Boolean(item)); +} + +function buildTurnFromMetadata(value: unknown, fallback: Omit, fallbackCreatedAt: number, index: number): EcommerceHistoryTurn | null { + const turn = objectValue(value); + if (!Object.keys(turn).length) return null; + const output = normalizeOutput(turn.output ?? fallback.output); + const platform = normalizePlatform(stringValue(turn.platform, fallback.platform)); + const market = normalizeMarket(stringValue(turn.market, fallback.market)); + const language = normalizeLanguageForPlatform(platform, market, stringValue(turn.language, fallback.language)); + const ratio = normalizeRatioForPlatform(platform, stringValue(turn.ratio, fallback.ratio), output === "hot" ? undefined : output); + const results = normalizeHistoryResults(turn.results, fallback.results); + const setResultImages = stringArrayValue(turn.setResultImages).length ? stringArrayValue(turn.setResultImages) : fallback.setResultImages; + const status = normalizeStatus(turn.status ?? fallback.status); + return { + id: stringValue(turn.id, `server-turn-${index + 1}`), + createdAt: timestampValue(turn.createdAt, fallbackCreatedAt), + status, + errorMessage: status === "failed" ? stringValue(turn.errorMessage, fallback.errorMessage) : undefined, + output, + modeLabel: typeof turn.modeLabel === "string" ? turn.modeLabel : fallback.modeLabel, + settingLabel: typeof turn.settingLabel === "string" ? turn.settingLabel : fallback.settingLabel, + generationKind: normalizeGenerationKind(turn.generationKind ?? fallback.generationKind, output), + platform, + market, + language, + ratio, + requirement: stringValue(turn.requirement, fallback.requirement), + productImages: normalizeHistoryImages(turn.productImages, fallback.productImages), + results, + setResultImages, + setCounts: normalizeSetCounts(turn.setCounts ?? fallback.setCounts), + detailModules: stringArrayValue(turn.detailModules).length ? stringArrayValue(turn.detailModules) : fallback.detailModules, + modelScenes: stringArrayValue(turn.modelScenes).length ? stringArrayValue(turn.modelScenes) : fallback.modelScenes, + referenceImages: normalizeHistoryImages(turn.referenceImages, fallback.referenceImages), + replicateLevel: normalizeReplicateLevel(turn.replicateLevel ?? fallback.replicateLevel), + }; +} + +export function ecommerceHistoryRecordFromGenerationRecord(record: GenerationRecord): EcommerceHistoryRecord | null { + if (record.tool !== "ecommerce") return null; + + const createdAt = timestampValue(record.createdAt, Date.now()); + const output = normalizeOutput(record.mode); + const config = objectValue(record.config); + const metadata = objectValue(record.metadata); + const sourceImages = record.assets.filter((asset) => asset.role === "source").map(imageFromAsset); + const results = record.assets.filter((asset) => asset.role === "result").map(resultFromAsset); + const hasHistoryMarker = metadata.localHistoryStorageKey === ecommerceHistoryStorageKey || typeof metadata.turnCount === "number"; + if (!hasHistoryMarker && record.status !== "completed") return null; + if (!hasHistoryMarker && !sourceImages.length && !results.length) return null; + const platform = normalizePlatform(stringValue(config.platform, defaultEcommercePlatform)); + const market = normalizeMarket(stringValue(config.market, marketOptions[0])); + const language = normalizeLanguageForPlatform(platform, market, stringValue(config.language, getPlatformDefaultLanguage(platform, market))); + const ratio = normalizeRatioForPlatform(platform, stringValue(config.ratio, getPlatformDefaultRatio(platform, output === "hot" ? undefined : output)), output === "hot" ? undefined : output); + const setResultImages = results.filter((item) => item.type !== "video").map((item) => item.src); + const status = normalizeStatus(record.status); + const baseTurn: Omit = { + status, + errorMessage: status === "failed" ? "生成失败" : undefined, + output, + modeLabel: typeof metadata.modeLabel === "string" ? metadata.modeLabel : undefined, + settingLabel: typeof metadata.settingLabel === "string" ? metadata.settingLabel : undefined, + generationKind: normalizeGenerationKind(metadata.generationKind, output), + platform, + market, + language, + ratio, + requirement: record.prompt ?? "", + productImages: sourceImages, + results, + setResultImages: output === "set" ? setResultImages : [], + setCounts: normalizeSetCounts(config.setCounts), + detailModules: stringArrayValue(config.detailModules).length ? stringArrayValue(config.detailModules) : defaultCloneDetailModuleIds, + modelScenes: stringArrayValue(config.modelScenes), + referenceImages: normalizeHistoryImages(metadata.referenceImages), + replicateLevel: normalizeReplicateLevel(config.replicateLevel), + }; + const turns = Array.isArray(metadata.turns) + ? metadata.turns + .map((turn, index) => buildTurnFromMetadata(turn, baseTurn, createdAt, index)) + .filter((turn): turn is EcommerceHistoryTurn => Boolean(turn)) + : []; + const latestTurn = turns[turns.length - 1] ?? { id: `${record.clientRecordId}-turn-initial`, createdAt, ...baseTurn }; + + return normalizeEcommerceHistoryRecord({ + id: record.clientRecordId, + title: record.title || record.prompt || "生成记录", + createdAt, + status: latestTurn.status, + errorMessage: latestTurn.errorMessage, + output: latestTurn.output, + modeLabel: latestTurn.modeLabel, + settingLabel: latestTurn.settingLabel, + generationKind: latestTurn.generationKind, + platform: latestTurn.platform, + market: latestTurn.market, + language: latestTurn.language, + ratio: latestTurn.ratio, + requirement: latestTurn.requirement, + productImages: latestTurn.productImages, + results: latestTurn.results, + setResultImages: latestTurn.setResultImages, + setCounts: latestTurn.setCounts, + detailModules: latestTurn.detailModules, + modelScenes: latestTurn.modelScenes, + referenceImages: latestTurn.referenceImages, + replicateLevel: latestTurn.replicateLevel, + turns: turns.length ? turns : [latestTurn], + }); +} + +export async function listEcommerceGenerationHistory(limit = 30): Promise { + const payload = await listGenerationRecords({ tool: "ecommerce", limit }); + return payload.items + .map(ecommerceHistoryRecordFromGenerationRecord) + .filter((record): record is EcommerceHistoryRecord => Boolean(record)) + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, limit); +} diff --git a/src/features/ecommerce/panels/CommandHistorySidebar.tsx b/src/features/ecommerce/panels/CommandHistorySidebar.tsx new file mode 100644 index 0000000..03c341d --- /dev/null +++ b/src/features/ecommerce/panels/CommandHistorySidebar.tsx @@ -0,0 +1,116 @@ +import { DeleteOutlined, MenuFoldOutlined, MenuUnfoldOutlined, ReloadOutlined } from "@ant-design/icons"; +import type { MouseEvent as ReactMouseEvent } from "react"; +import type { EcommerceHistoryRecord } from "../utils/clonePersistence"; + +interface CommandHistorySidebarProps { + collapsed: boolean; + showBackdrop: boolean; + records: EcommerceHistoryRecord[]; + activeRecordId: string | null; + isRefreshing: boolean; + refreshMessage: string | null; + refreshStamp: number; + refreshTick: number; + outputLabels: Array<{ key: string; label: string }>; + formatHistoryTime: (timestamp: number) => string; + onToggleCollapsed: () => void; + onCollapse: () => void; + onNewConversation: () => void; + onRefresh: () => void; + onOpenRecord: (record: EcommerceHistoryRecord) => void; + onDeleteRecord: (recordId: string, event: ReactMouseEvent) => void; +} + +// 生成记录侧栏:折叠/展开、新建对话、刷新历史、记录列表(点击查看/删除)。 +export default function CommandHistorySidebar({ + collapsed, + showBackdrop, + records, + activeRecordId, + isRefreshing, + refreshMessage, + refreshStamp, + refreshTick, + outputLabels, + formatHistoryTime, + onToggleCollapsed, + onCollapse, + onNewConversation, + onRefresh, + onOpenRecord, + onDeleteRecord, +}: CommandHistorySidebarProps) { + return ( + <> + {showBackdrop ? ( +
+ ) : null} + + + + ); +} diff --git a/src/features/ecommerce/panels/EcommerceCopywritingPanel.tsx b/src/features/ecommerce/panels/EcommerceCopywritingPanel.tsx index cefb105..2b9f88d 100644 --- a/src/features/ecommerce/panels/EcommerceCopywritingPanel.tsx +++ b/src/features/ecommerce/panels/EcommerceCopywritingPanel.tsx @@ -4,7 +4,6 @@ import { CopyOutlined, EditOutlined, FileTextOutlined, - FireOutlined, GlobalOutlined, MessageOutlined, SmileOutlined, diff --git a/src/features/ecommerce/panels/EcommerceOneClickVideoPanel.tsx b/src/features/ecommerce/panels/EcommerceOneClickVideoPanel.tsx new file mode 100644 index 0000000..c50c0f8 --- /dev/null +++ b/src/features/ecommerce/panels/EcommerceOneClickVideoPanel.tsx @@ -0,0 +1,408 @@ +import { + FileImageOutlined, + PlusOutlined, + ThunderboltOutlined, + VideoCameraOutlined, +} from "@ant-design/icons"; +import { useMemo, useRef, useState, type ChangeEvent, type DragEvent, type KeyboardEvent, type RefObject } from "react"; +import EcommerceVideoWorkspace from "../EcommerceVideoWorkspace"; + +interface CloneImageItem { + id: string; + src: string; + name: string; + file?: File; +} + +type CloneVideoQualityKey = "standard" | "high" | "ultra"; + +interface EcommerceOneClickVideoPanelProps { + onClose: () => void; + isAuthenticated: boolean; + onRequestLogin: () => void; + productImages: CloneImageItem[]; + productInputRef: RefObject; + isProductUploadDragging: boolean; + setIsProductUploadDragging: (value: boolean) => void; + handleProductDrop: (event: DragEvent) => void; + handleProductUpload: (event: ChangeEvent) => void; + removeProductImage: (imageId: string) => void; + maxProductImages: number; + requirement: string; + onRequirementChange: (value: string) => void; + platform: string; + platformOptions: string[]; + onPlatformChange: (value: string) => void; + ratio: string; + ratioOptions: string[]; + onRatioChange: (value: string) => void; + videoQuality: CloneVideoQualityKey; + videoQualityOptions: Array<{ key: CloneVideoQualityKey; label: string; desc: string }>; + onVideoQualityChange: (value: CloneVideoQualityKey) => void; + videoDuration: number; + videoDurationMin: number; + videoDurationMax: number; + onVideoDurationChange: (value: number) => void; + videoSmart: boolean; + onVideoSmartChange: (value: boolean) => void; + onOpenHistory: () => void; +} + +function getVideoAspectRatio(ratio: string): string { + if (ratio.includes("9:16")) return "9:16"; + if (ratio.includes("16:9")) return "16:9"; + if (ratio.includes("3:4")) return "3:4"; + return "9:16"; +} + +function openQuickUploadWithKeyboard( + event: KeyboardEvent, + inputRef: { current: HTMLInputElement | null }, +) { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + inputRef.current?.click(); +} + +export default function EcommerceOneClickVideoPanel({ + onClose, + isAuthenticated, + onRequestLogin, + productImages, + productInputRef, + isProductUploadDragging, + setIsProductUploadDragging, + handleProductDrop, + handleProductUpload, + removeProductImage, + maxProductImages, + requirement, + onRequirementChange, + platform, + platformOptions, + onPlatformChange, + ratio, + ratioOptions, + onRatioChange, + videoQuality, + videoQualityOptions, + onVideoQualityChange, + videoDuration, + videoDurationMin, + videoDurationMax, + onVideoDurationChange, + videoSmart, + onVideoSmartChange, + onOpenHistory, +}: EcommerceOneClickVideoPanelProps) { + const [openSelect, setOpenSelect] = useState<"platform" | "ratio" | null>(null); + const [planTrigger, setPlanTrigger] = useState(0); + const selectAnchorRef = useRef(null); + + const productImageDataUrls = useMemo(() => productImages.map((img) => img.src), [productImages]); + const productImageFiles = useMemo(() => productImages.map((img) => img.file), [productImages]); + + const canGenerate = productImages.length > 0 || requirement.trim().length > 0; + + const handleGenerate = () => { + if (!isAuthenticated) { + onRequestLogin(); + return; + } + setPlanTrigger((value) => value + 1); + }; + + const handlePlatformSelect = (value: string) => { + onPlatformChange(value); + setOpenSelect(null); + }; + + const handleRatioSelect = (value: string) => { + onRatioChange(value); + setOpenSelect(null); + }; + + const toggleSelect = (key: "platform" | "ratio") => { + setOpenSelect((current) => (current === key ? null : key)); + }; + + const renderThumbs = () => ( +
+ {productImages.map((item) => ( +
+ {item.name} + + +
+ ))} +
+ ); + + return ( +
+
+