feat: 增加了模特的维护

This commit is contained in:
2025-06-27 14:46:11 +08:00
parent 6ed7e10ccc
commit 8936b898e2
24 changed files with 1289 additions and 879 deletions

2
.gitignore vendored
View File

@@ -22,3 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.env

View File

@@ -4,12 +4,15 @@
"": {
"name": "web",
"dependencies": {
"@hookform/resolvers": "^5.1.1",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/vite": "^4.1.11",
"axios": "^1.6.0",
"class-variance-authority": "^0.7.1",
@@ -17,10 +20,13 @@
"lucide-react": "^0.523.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.58.1",
"react-router-dom": "^7.6.2",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.25.67",
"zustand": "^5.0.6",
},
"devDependencies": {
"@eslint/js": "^9.29.0",
@@ -42,6 +48,8 @@
},
},
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
"@apidevtools/json-schema-ref-parser": ["@apidevtools/json-schema-ref-parser@10.1.0", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.11", "@types/lodash.clonedeep": "^4.5.7", "js-yaml": "^4.1.0", "lodash.clonedeep": "^4.5.0" } }, "sha512-3e+viyMuXdrcK8v5pvP+SDoAQ77FH6OyRmuK48SZKmdHJRFm87RsSs8qm6kP39a/pOPURByJw+OXzQIqcfmKtA=="],
@@ -126,6 +134,8 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.9", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="],
"@hookform/resolvers": ["@hookform/resolvers@5.1.1", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-J/NVING3LMAEvexJkyTLjruSm7aOFx7QX21pzkiJfMoNG0wl5aFEjLTl7ay7IQb9EWY6AkrBy7tHL2Alijpdcg=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="],
@@ -154,16 +164,22 @@
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="],
@@ -182,6 +198,8 @@
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA=="],
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
@@ -258,6 +276,8 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.44.1", "", { "os": "win32", "cpu": "x64" }, "sha512-J8o22LuF0kTe7m+8PvW9wk3/bRq5+mRo5Dqo6+vXb7otCm3TPhYOJqOaQtGU9YMWQSL3krMnoOxMr0+9E6F3Ug=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@swc/core": ["@swc/core@1.12.7", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.23" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.12.7", "@swc/core-darwin-x64": "1.12.7", "@swc/core-linux-arm-gnueabihf": "1.12.7", "@swc/core-linux-arm64-gnu": "1.12.7", "@swc/core-linux-arm64-musl": "1.12.7", "@swc/core-linux-x64-gnu": "1.12.7", "@swc/core-linux-x64-musl": "1.12.7", "@swc/core-win32-arm64-msvc": "1.12.7", "@swc/core-win32-ia32-msvc": "1.12.7", "@swc/core-win32-x64-msvc": "1.12.7" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-bcpllEihyUSnqp0UtXTvXc19CT4wp3tGWLENhWnjr4B5iEOkzqMu+xHGz1FI5IBatjfqOQb29tgIfv6IL05QaA=="],
"@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.12.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-w6BBT0hBRS56yS+LbReVym0h+iB7/PpCddqrn1ha94ra4rZ4R/A91A/rkv+LnQlPqU/+fhqdlXtCJU9mrhCBtA=="],
@@ -312,6 +332,8 @@
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.11", "", { "os": "win32", "cpu": "x64" }, "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg=="],
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.11", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.11", "@tailwindcss/oxide": "4.1.11", "postcss": "^8.4.41", "tailwindcss": "4.1.11" } }, "sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.11", "", { "dependencies": { "@tailwindcss/node": "4.1.11", "@tailwindcss/oxide": "4.1.11", "tailwindcss": "4.1.11" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -650,6 +672,8 @@
"react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="],
"react-hook-form": ["react-hook-form@7.58.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-Lml/KZYEEFfPhUVgE0RdCVpnC4yhW+PndRhbiTtdvSlQTL8IfVR+iQkBjLIvmmc6+GGoVeM11z37ktKFPAb0FA=="],
"react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
@@ -746,6 +770,10 @@
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@3.25.67", "", {}, "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw=="],
"zustand": ["zustand@5.0.6", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-ihAqNeUVhe0MAD+X8M5UzqyZ9k3FFZLBTtqo6JLPwV53cbRB/mJwBI0PxcIgqhBBHlEs8G45OTDTMq3gNcLq3A=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],

View File

@@ -13,12 +13,15 @@
"generate-api:prod": "OPENAPI_URL=https://your-api-domain.com/openapi.json node scripts/generate-api.js"
},
"dependencies": {
"@hookform/resolvers": "^5.1.1",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/vite": "^4.1.11",
"axios": "^1.6.0",
"class-variance-authority": "^0.7.1",
@@ -26,10 +29,13 @@
"lucide-react": "^0.523.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.58.1",
"react-router-dom": "^7.6.2",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
"tailwindcss-animate": "^1.0.7"
"tailwindcss-animate": "^1.0.7",
"zod": "^3.25.67",
"zustand": "^5.0.6"
},
"devDependencies": {
"@eslint/js": "^9.29.0",

View File

@@ -1,6 +1,6 @@
export default {
plugins: {
tailwindcss: {},
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}
};

View File

@@ -1,7 +1,8 @@
import { Routes, Route, Link, useLocation } from 'react-router-dom';
import ModelPage from './pages/ModelPage';
import { SidebarProvider, Sidebar, SidebarContent, SidebarMenu, SidebarMenuItem, SidebarMenuButton } from '@/components/ui/sidebar';
import { ConfirmDialog } from '@/components/block/confirm-dialog';
import { Sidebar, SidebarContent, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from '@/components/ui/sidebar';
import { Home, Users } from 'lucide-react';
import { Link, Route, Routes, useLocation } from 'react-router-dom';
import ModelPage from './pages/ModelPage';
const menu = [
{ path: '/', label: '首页', icon: <Home className='mr-2' /> },
@@ -36,6 +37,7 @@ function App() {
<Route path='/models' element={<ModelPage />} />
</Routes>
</main>
<ConfirmDialog />
</div>
</SidebarProvider>
);

View File

@@ -23,10 +23,8 @@ export type { Body_opt_local_generate_image_api_local_batch_generate_image_lates
export type { Body_sync_custom_edit_image_api_sync_custom_image_post } from './models/Body_sync_custom_edit_image_api_sync_custom_image_post';
export type { Body_update_model_api_models__model_id__put } from './models/Body_update_model_api_models__model_id__put';
export type { Body_upload_image_api_upload_post } from './models/Body_upload_image_api_upload_post';
export type { Body_upload_img_material_api_img_upload_material_post } from './models/Body_upload_img_material_api_img_upload_material_post';
export type { HTTPValidationError } from './models/HTTPValidationError';
export type { ImageEditItem } from './models/ImageEditItem';
export type { ScenarioItem } from './models/ScenarioItem';
export type { TaskQueryRequest } from './models/TaskQueryRequest';
export type { UploadResponse } from './models/UploadResponse';
export type { ValidationError } from './models/ValidationError';
@@ -50,10 +48,8 @@ export { $Body_opt_local_generate_image_api_local_batch_generate_image_latest_po
export { $Body_sync_custom_edit_image_api_sync_custom_image_post } from './schemas/$Body_sync_custom_edit_image_api_sync_custom_image_post';
export { $Body_update_model_api_models__model_id__put } from './schemas/$Body_update_model_api_models__model_id__put';
export { $Body_upload_image_api_upload_post } from './schemas/$Body_upload_image_api_upload_post';
export { $Body_upload_img_material_api_img_upload_material_post } from './schemas/$Body_upload_img_material_api_img_upload_material_post';
export { $HTTPValidationError } from './schemas/$HTTPValidationError';
export { $ImageEditItem } from './schemas/$ImageEditItem';
export { $ScenarioItem } from './schemas/$ScenarioItem';
export { $TaskQueryRequest } from './schemas/$TaskQueryRequest';
export { $UploadResponse } from './schemas/$UploadResponse';
export { $ValidationError } from './schemas/$ValidationError';

View File

@@ -1,9 +0,0 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type Body_upload_img_material_api_img_upload_material_post = {
category: string;
file: Blob;
};

View File

@@ -1,9 +0,0 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type ScenarioItem = {
category: string;
description: string;
};

View File

@@ -14,10 +14,8 @@ export * from './Body_opt_local_generate_image_api_local_batch_generate_image_la
export * from './Body_sync_custom_edit_image_api_sync_custom_image_post';
export * from './Body_update_model_api_models__model_id__put';
export * from './Body_upload_image_api_upload_post';
export * from './Body_upload_img_material_api_img_upload_material_post';
export * from './HTTPValidationError';
export * from './ImageEditItem';
export * from './ScenarioItem';
export * from './TaskQueryRequest';
export * from './UploadResponse';
export * from './ValidationError';

View File

@@ -1,17 +0,0 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $Body_upload_img_material_api_img_upload_material_post = {
properties: {
category: {
type: 'string',
isRequired: true,
},
file: {
type: 'binary',
isRequired: true,
format: 'binary',
},
},
} as const;

View File

@@ -1,16 +0,0 @@
/* generated using openapi-typescript-codegen -- do no edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export const $ScenarioItem = {
properties: {
category: {
type: 'string',
isRequired: true,
},
description: {
type: 'string',
isRequired: true,
},
},
} as const;

View File

@@ -6,8 +6,6 @@ import type { BatchVideoGenerateRequest } from '../models/BatchVideoGenerateRequ
import type { BatchVideoTaskRequest } from '../models/BatchVideoTaskRequest';
import type { Body_create_model_api_models_create_post } from '../models/Body_create_model_api_models_create_post';
import type { Body_update_model_api_models__model_id__put } from '../models/Body_update_model_api_models__model_id__put';
import type { Body_upload_img_material_api_img_upload_material_post } from '../models/Body_upload_img_material_api_img_upload_material_post';
import type { ScenarioItem } from '../models/ScenarioItem';
import type { VideoGenerateRequest } from '../models/VideoGenerateRequest';
import type { VideoTaskStatus } from '../models/VideoTaskStatus';
import type { CancelablePromise } from '../core/CancelablePromise';
@@ -92,33 +90,39 @@ export class Service {
});
}
/**
* 上传素材
* 上传图片素材
* 创建模型
* 创建模型
*
* Args:
* category: 素材所属的类别, 例如, women man
* file: 上传的图片文件
* tags: 模型标签格式性别_类别_尺寸_材质_颜色
* cover_image: 封面图片文件
* status: 状态默认为1
* db: 数据库会话
*
* Returns:
* dict: 包含上传结果的响应
* dict: 包含创建结果的响应
* {
* 'status': 上传的状态,
* 'status': 创建状态,
* 'data': {
* 'id': 素材id,
* 'img_url': '素材列表'
* }
* 'id': 模型ID,
* 'tag': {'sex': '性别', 'category': '类别', 'size': '尺寸', 'material': '材质', 'color': '颜色'},
* 'cover_url': '封面URL',
* 'status': 状态,
* 'create_time': '创建时间'
* },
* 'msg': '提示信息'
* }
* @returns any Successful Response
* @throws ApiError
*/
public static uploadImgMaterialApiImgUploadMaterialPost({
public static createModelApiModelsCreatePost({
formData,
}: {
formData: Body_upload_img_material_api_img_upload_material_post,
formData: Body_create_model_api_models_create_post,
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/img/upload/material',
url: '/api/models/create',
formData: formData,
mediaType: 'multipart/form-data',
errors: {
@@ -127,554 +131,272 @@ export class Service {
});
}
/**
* 获取模特素材分类列表
* 获取模特素材分类列表
* 获取模列表
* 获取模列表
*
* Args:
* status: 状态筛选1启用0禁用为空时获取所有
* db: 数据库会话
*
* Returns:
* dict: 包含分类列表的响应
* dict: 包含模型列表的响应
* {
* 'status': 查询状态,
* 'data': ['category1', 'category2', ...],
* 'data': [模型信息列表],
* 'msg': '提示信息'
* }
* @returns any Successful Response
* @throws ApiError
*/
public static getImgCategoryListApiImgCategoriesGet(): CancelablePromise<any> {
public static getModelsListApiModelsListGet({
status,
}: {
/**
* 状态筛选1启用0禁用
*/
status?: (number | null),
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/img/categories',
url: '/api/models/list',
query: {
'status': status,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* 获取某个类别下模特素材列表,类别为空获取所有
* 获取某个类别下的模特素材列表
* 更新模型
* 更新模型信息
*
* Args:
* category: 素材类别列表,支持多个类别,为空时获取所有模特素材
* 例如: ?category=women&category=men&category=kids
* model_id: 模型ID
* tags: 更新的标签格式性别_类别_尺寸_材质_颜色可选
* cover_image: 更新的封面图片文件(可选)
* status: 更新的状态(可选)
* db: 数据库会话
*
* Returns:
* dict: 包含素材列表的响应
* dict: 包含更新结果的响应
* @returns any Successful Response
* @throws ApiError
*/
public static updateModelApiModelsModelIdPut({
modelId,
formData,
}: {
modelId: number,
formData?: Body_update_model_api_models__model_id__put,
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'PUT',
url: '/api/models/{model_id}',
path: {
'model_id': modelId,
},
formData: formData,
mediaType: 'multipart/form-data',
errors: {
422: `Validation Error`,
},
});
}
/**
* 删除单个模型
* 删除单个模型(软删除)
*
* Args:
* model_id: 模型ID
* db: 数据库会话
*
* Returns:
* dict: 删除结果
* @returns any Successful Response
* @throws ApiError
*/
public static deleteModelApiModelsModelIdDelete({
modelId,
}: {
modelId: number,
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/api/models/{model_id}',
path: {
'model_id': modelId,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* 删除模型,支持批量
* 批量删除模型(软删除)
*
* Args:
* model_ids: 模型ID列表
* db: 数据库会话
*
* Returns:
* dict: 删除结果
* {
* 'status': 查询状态,
* 'data': ['图片URL1', '图片URL2', ...],
* 'msg': '提示信息'
* }
* @returns any Successful Response
* @throws ApiError
*/
public static getModelListApiImgModelListGet({
category,
}: {
category?: (Array<string> | null),
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/img/model/list',
query: {
'category': category,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* 删除图片,支持批量
* 批量删除图片(软删除)
*
* Args:
* img_url: 图片URL列表
* db: 数据库会话
*
* Returns:
* dict: 删除结果
* @returns any Successful Response
* @throws ApiError
*/
public static removeImageApiImgRemovePost({
requestBody,
}: {
requestBody: Array<string>,
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/img/remove',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* 添加场景信息
* 添加场景信息
*
* Args:
* data: 场景数据包含category和description
* db: 数据库会话
*
* Returns:
* dict: 包含添加结果的响应
* {
* 'status': 添加状态,
* 'data': {
* 'id': 场景ID,
* 'category': '类别',
* 'description': '描述'
* },
* 'msg': '提示信息'
* }
* @returns any Successful Response
* @throws ApiError
*/
public static addScenarioInfoApiScenarioAddPost({
requestBody,
}: {
requestBody: ScenarioItem,
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/scenario/add',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
* 'status': 删除状态,
* 'data': {
* 'total': 总数,
* 'success': 成功数,
* 'failed': 失败数
* },
* 'msg': '提示信息'
* }
* @returns any Successful Response
* @throws ApiError
*/
public static removeModelsApiModelsRemovePost({
requestBody,
}: {
requestBody: Array<number>,
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/models/remove',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* 根据标签筛选模型列表
* 根据标签筛选模型列表
* 支持格式性别_类别_尺寸_材质_颜色使用特殊匹配规则
*
* 匹配规则:
* - sex性别和 category类别等值匹配必须完全相同AND逻辑
* - size尺寸、material材质、color颜色非等值匹配所有字段都必须不同AND逻辑
*
* Args:
* tags: 标签字符串格式性别_类别_尺寸_材质_颜色
* status: 状态筛选默认为1
* db: 数据库会话
*
* Returns:
* dict: 包含筛选结果的响应
* @returns any Successful Response
* @throws ApiError
*/
public static getModelsByTagApiModelsByTagsGet({
tags,
status,
}: {
/**
* 获取场景信息列表
* 获取场景信息列表
*
* Args:
* db: 数据库会话
*
* Returns:
* dict: 包含场景分类列表的响应
* {
* 'status': 查询状态,
* 'data': ['类别1', '类别2', ...],
* 'msg': '提示信息'
* }
* @returns any Successful Response
* @throws ApiError
*/
public static getScenarioListApiScenarioListGet(): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/scenario/list',
});
}
/**
* 获取场景详情列表
* 获取场景详情列表
*
* Args:
* category: 场景类别列表,支持多个类别,为空时获取所有场景详情
* 例如: ?category=indoor&category=outdoor&category=studio
* db: 数据库会话
*
* Returns:
* dict: 包含场景描述列表的响应
* {
* 'status': 查询状态,
* 'data': ['描述1', '描述2', ...],
* 'msg': '提示信息'
* }
* @returns any Successful Response
* @throws ApiError
*/
public static getScenarioDetailListApiScenarioDetailListGet({
category,
}: {
category?: (Array<string> | null),
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/scenario/detail/list',
query: {
'category': category,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* 删除场景,支持批量
* 批量删除场景(软删除)
*
* Args:
* data_list: 场景值列表
* db: 数据库会话
*
* Returns:
* dict: 删除结果
* @returns any Successful Response
* @throws ApiError
*/
public static removeScenarioApiScenarioRemovePost({
requestBody,
}: {
requestBody: Array<string>,
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/scenario/remove',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* 创建模型
* 创建模型
*
* Args:
* tags: 模型标签格式性别_类别_尺寸_材质_颜色
* cover_image: 封面图片文件
* status: 状态默认为1
* db: 数据库会话
*
* Returns:
* dict: 包含创建结果的响应
* {
* 'status': 创建状态,
* 'data': {
* 'id': 模型ID,
* 'tag': {'sex': '性别', 'category': '类别', 'size': '尺寸', 'material': '材质', 'color': '颜色'},
* 'cover_url': '封面URL',
* 'status': 状态,
* 'create_time': '创建时间'
* },
* 'msg': '提示信息'
* }
* @returns any Successful Response
* @throws ApiError
*/
public static createModelApiModelsCreatePost({
formData,
}: {
formData: Body_create_model_api_models_create_post,
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/models/create',
formData: formData,
mediaType: 'multipart/form-data',
errors: {
422: `Validation Error`,
},
});
}
/**
* 获取模型列表
* 获取模型列表
*
* Args:
* status: 状态筛选1启用0禁用为空时获取所有
* db: 数据库会话
*
* Returns:
* dict: 包含模型列表的响应
* {
* 'status': 查询状态,
* 'data': [模型信息列表],
* 'msg': '提示信息'
* }
* @returns any Successful Response
* @throws ApiError
*/
public static getModelsListApiModelsListGet({
status,
}: {
/**
* 状态筛选1启用0禁用
*/
status?: (number | null),
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/models/list',
query: {
'status': status,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* 更新模型
* 更新模型信息
*
* Args:
* model_id: 模型ID
* tags: 更新的标签格式性别_类别_尺寸_材质_颜色可选
* cover_image: 更新的封面图片文件(可选)
* status: 更新的状态(可选)
* db: 数据库会话
*
* Returns:
* dict: 包含更新结果的响应
* @returns any Successful Response
* @throws ApiError
*/
public static updateModelApiModelsModelIdPut({
modelId,
formData,
}: {
modelId: number,
formData?: Body_update_model_api_models__model_id__put,
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'PUT',
url: '/api/models/{model_id}',
path: {
'model_id': modelId,
},
formData: formData,
mediaType: 'multipart/form-data',
errors: {
422: `Validation Error`,
},
});
}
/**
* 删除单个模型
* 删除单个模型(软删除)
*
* Args:
* model_id: 模型ID
* db: 数据库会话
*
* Returns:
* dict: 删除结果
* @returns any Successful Response
* @throws ApiError
*/
public static deleteModelApiModelsModelIdDelete({
modelId,
}: {
modelId: number,
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/api/models/{model_id}',
path: {
'model_id': modelId,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* 删除模型,支持批量
* 批量删除模型(软删除)
*
* Args:
* model_ids: 模型ID列表
* db: 数据库会话
*
* Returns:
* dict: 删除结果
* {
* 'status': 删除状态,
* 'data': {
* 'total': 总数,
* 'success': 成功数,
* 'failed': 失败数
* },
* 'msg': '提示信息'
* }
* @returns any Successful Response
* @throws ApiError
*/
public static removeModelsApiModelsRemovePost({
requestBody,
}: {
requestBody: Array<number>,
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/models/remove',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* 根据标签筛选模型列表
* 根据标签筛选模型列表
* 支持格式性别_类别_尺寸_材质_颜色使用特殊匹配规则
*
* 匹配规则:
* - sex性别和 category类别等值匹配必须完全相同AND逻辑
* - size尺寸、material材质、color颜色非等值匹配所有字段都必须不同AND逻辑
*
* Args:
* tags: 标签字符串格式性别_类别_尺寸_材质_颜色
* status: 状态筛选默认为1
* db: 数据库会话
*
* Returns:
* dict: 包含筛选结果的响应
* @returns any Successful Response
* @throws ApiError
*/
public static getModelsByTagApiModelsByTagsGet({
tags,
status,
}: {
/**
* 标签格式性别_类别_尺寸_材质_颜色。例如: '女_上衣_长款_牛仔_红'
*/
tags: string,
/**
* 状态筛选1启用0禁用
*/
status?: (number | null),
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/models/by/tags',
query: {
'tags': tags,
'status': status,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* 获取风格标签结构(外层风格 + 内层场景)
* @returns any Successful Response
* @throws ApiError
*/
public static fetchStyleAvailableTagsApiTagStyleTagListGet(): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/tag/style/tag_list',
});
}
/**
* 获取指定风格和多个场景的值列表
* 获取指定风格和多个场景下的值列表(最内层数组合并)
*
* Args:
* style: 风格名称,如:少女、马来
* scenes: 场景名称,多个用逗号分隔,如:室内,室外
*
* Returns:
* dict: 合并后的值列表
* @returns any Successful Response
* @throws ApiError
*/
public static fetchStyleSceneValuesApiTagStyleValuesGet({
style,
scenes,
}: {
style: string,
scenes: string,
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/tag/style/values',
query: {
'style': style,
'scenes': scenes,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* 获取模特标签列表
* @returns any Successful Response
* @throws ApiError
*/
public static fetchModelTagsApiTagModelTagListGet(): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/tag/model/tag_list',
});
}
/**
* 极梦视频生成任务提交
* 生成视频API
* @returns any Successful Response
* @throws ApiError
*/
public static generateVideoApiVideoGeneratePost({
requestBody,
}: {
requestBody: VideoGenerateRequest,
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/video/generate',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* 最大支持同时提交5个任务
* 批量生成视频API - 并发处理最多同时5个任务
* @returns any Successful Response
* @throws ApiError
*/
public static batchGenerateVideoApiBatchVideoGeneratePost({
requestBody,
}: {
requestBody: BatchVideoGenerateRequest,
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/batch/video/generate',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* 极梦视频生成进度查询
* 查询视频生成状态API
* @returns any Successful Response
* @throws ApiError
*/
public static queryVideoStatusApiVideoQueryTaskIdGet({
taskId,
}: {
taskId: string,
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/video/query/{task_id}',
path: {
'task_id': taskId,
},
errors: {
422: `Validation Error`,
},
});
}
}
* 标签格式性别_类别_尺寸_材质_颜色。例如: '女_上衣_长款_牛仔_红'
*/
tags: string,
/**
* 状态筛选1启用0禁用
*/
status?: (number | null),
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/models/by/tags',
query: {
'tags': tags,
'status': status,
},
errors: {
422: `Validation Error`,
},
});
}
/**
* 获取风格标签结构(外层风格 + 内层场景)
* @returns any Successful Response
* @throws ApiError
*/
public static fetchStyleAvailableTagsApiTagStyleTagListGet(): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/tag/style/tag_list',
});
}
/**
* 获取模特标签列表
* @returns any Successful Response
* @throws ApiError
*/
public static fetchModelTagsApiTagModelTagListGet(): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/tag/model/tag_list',
});
}
/**
* 极梦视频生成任务提交
* 生成视频API
* @returns any Successful Response
* @throws ApiError
*/
public static generateVideoApiVideoGeneratePost({
requestBody,
}: {
requestBody: VideoGenerateRequest,
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/video/generate',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* 最大支持同时提交5个任务
* 批量生成视频API - 并发处理最多同时5个任务
* @returns any Successful Response
* @throws ApiError
*/
public static batchGenerateVideoApiBatchVideoGeneratePost({
requestBody,
}: {
requestBody: BatchVideoGenerateRequest,
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/batch/video/generate',
body: requestBody,
mediaType: 'application/json',
errors: {
422: `Validation Error`,
},
});
}
/**
* 极梦视频生成进度查询
* 查询视频生成状态API
* @returns any Successful Response
* @throws ApiError
*/
public static queryVideoStatusApiVideoQueryTaskIdGet({
taskId,
}: {
taskId: string,
}): CancelablePromise<any> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/video/query/{task_id}',
path: {
'task_id': taskId,
},
errors: {
422: `Validation Error`,
},
});
}
}

View File

@@ -0,0 +1,145 @@
'use client';
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogFooter } from '@/components/ui/dialog';
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import type { FieldValues, UseFormReturn } from 'react-hook-form';
import { cn } from '@/lib/utils';
interface ConfirmDialogStore {
open: boolean;
content: React.ReactNode;
className?: string;
buttonText?: { cancel?: string; confirm?: string; loading?: string };
operations?: (props: { setOpen: (value: boolean) => void }) => React.ReactNode[];
onConfirm?: () => void | Promise<void>;
setOpen: (value: boolean) => void;
setContent: (content: React.ReactNode) => void;
setOnConfirm: (onConfirm?: () => void) => void;
setButtonText: (buttonText?: { cancel?: string; confirm?: string; loading?: string }) => void;
setOperations: (operations?: (props: { setOpen: (value: boolean) => void }) => React.ReactNode[]) => void;
setClassName: (className?: string) => void;
}
const useConfirmDialogStore = create<ConfirmDialogStore>()(
devtools(set => ({
open: false,
content: null,
className: undefined,
buttonText: { cancel: '取消', confirm: '', loading: '处理中' },
operations: () => [],
onConfirm: undefined,
setOpen: (value: boolean) => set({ open: value }),
setContent: content => set({ content }),
setOnConfirm: onConfirm => set({ onConfirm }),
setButtonText: buttonText => set({ buttonText }),
setOperations: operations => set({ operations }),
setClassName: className => set({ className }),
}))
);
export const ConfirmDialog = () => {
const {
open,
content,
buttonText = { cancel: '取消', confirm: '确认', loading: '处理中' },
operations,
onConfirm,
setOpen,
className,
} = useConfirmDialogStore();
const [isSubmitting, setIsSubmitting] = useState(false);
const handleConfirm = async () => {
setIsSubmitting(true);
try {
await onConfirm?.();
} finally {
setIsSubmitting(false);
setOpen(false);
}
};
return (
<Dialog open={open} onOpenChange={value => !isSubmitting && setOpen(value)}>
<DialogContent className={cn('w-[380px] pb-5 pl-6 pr-4 pt-8', className)}>
<div>{content}</div>
<DialogFooter>
{operations ? (
operations({ setOpen })
) : (
<>
<Button className='w-24' variant='outline' onClick={() => setOpen(false)} disabled={isSubmitting}>
{buttonText?.cancel || '取消'}
</Button>
{buttonText?.confirm && (
<Button className='w-24' onClick={handleConfirm} disabled={isSubmitting}>
{isSubmitting ? buttonText?.loading || '处理中' : buttonText?.confirm || '确定'}
</Button>
)}
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
ConfirmDialog.displayName = 'ConfirmDialog';
function confirm<F extends FieldValues = FieldValues, R = any>(props: {
content: React.ReactNode | ((props: { form?: UseFormReturn<F> }) => React.ReactNode);
className?: string;
buttonText?: { cancel?: string; confirm?: string; loading?: '处理中' };
operations?: (props: { setOpen: (value: boolean) => void }) => React.ReactNode[];
onConfirm?: (formData?: F) => R | Promise<R>;
form?: UseFormReturn<F>;
}) {
const { setOpen, setContent, setOnConfirm, setButtonText, setOperations, setClassName } = useConfirmDialogStore.getState();
const wrappedOnConfirm = async () => {
if (props.form) {
const formData = props.form.getValues();
await props.onConfirm?.(formData);
} else {
await props.onConfirm?.();
}
};
setContent(typeof props.content === 'function' ? props.content({ form: props.form }) : props.content);
setOnConfirm(wrappedOnConfirm);
setOperations(props.operations);
setButtonText(props.buttonText);
setClassName(props.className);
setOpen(true);
}
async function asyncConfirm<F extends FieldValues = FieldValues, R = any>(props: {
content: React.ReactNode | ((props: { form?: UseFormReturn<F> }) => React.ReactNode);
buttonText?: { cancel?: string; confirm?: string };
operations?: (props: { setOpen: (value: boolean) => void }) => React.ReactNode[];
onConfirm?: (formData?: F) => R | Promise<R>;
form?: UseFormReturn<F>;
}) {
const promise = new Promise<R>(resolve => {
confirm<F>({
content: props.content,
buttonText: props.buttonText,
operations: props.operations,
form: props.form,
onConfirm: async (formData?: F) => {
if (!props.onConfirm) {
resolve(undefined as any);
return;
}
const res = await props.onConfirm(formData as any);
resolve(res);
},
});
});
return promise;
}
ConfirmDialog.confirm = confirm;
ConfirmDialog.asyncConfirm = asyncConfirm;

View File

@@ -1,87 +0,0 @@
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
interface ModelFormProps {
initialData?: any;
onSubmit: (data: any, id?: number) => void;
onCancel: () => void;
editMode?: boolean;
}
const defaultTag = { sex: '', category: '', size: '', material: '', color: '' };
const ModelForm: React.FC<ModelFormProps> = ({ initialData, onSubmit, onCancel, editMode }) => {
const [tag, setTag] = useState(initialData?.tag || defaultTag);
const [cover, setCover] = useState<File | null>(null);
const [status, setStatus] = useState(initialData?.status ?? 1);
const [loading, setLoading] = useState(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setTag({ ...tag, [e.target.name]: e.target.value });
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
setCover(e.target.files[0]);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
const formData = new FormData();
formData.append('tags', Object.values(tag).join('_'));
if (cover) formData.append('cover_image', cover);
formData.append('status', String(status));
setLoading(false);
onSubmit(formData, initialData?.id);
};
return (
<form onSubmit={handleSubmit} className='space-y-4'>
<div className='grid grid-cols-2 gap-4'>
<div>
<Label htmlFor='sex'></Label>
<Input name='sex' value={tag.sex} onChange={handleChange} required />
</div>
<div>
<Label htmlFor='category'></Label>
<Input name='category' value={tag.category} onChange={handleChange} required />
</div>
<div>
<Label htmlFor='size'></Label>
<Input name='size' value={tag.size} onChange={handleChange} required />
</div>
<div>
<Label htmlFor='material'></Label>
<Input name='material' value={tag.material} onChange={handleChange} required />
</div>
<div>
<Label htmlFor='color'></Label>
<Input name='color' value={tag.color} onChange={handleChange} required />
</div>
<div>
<Label htmlFor='cover'></Label>
<Input type='file' name='cover' accept='image/*' onChange={handleFileChange} />
</div>
<div className='flex items-center space-x-2'>
<Label htmlFor='status'></Label>
<Switch checked={status === 1} onCheckedChange={v => setStatus(v ? 1 : 0)} />
</div>
</div>
<div className='flex justify-end space-x-2'>
<Button type='button' variant='outline' onClick={onCancel} disabled={loading}>
</Button>
<Button type='submit' disabled={loading}>
{loading ? '加载中...' : editMode ? '保存' : '创建'}
</Button>
</div>
</form>
);
};
export default ModelForm;

View File

@@ -1,39 +1,36 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
},
},
defaultVariants: {
variant: "default",
size: "default",
variant: 'default',
size: 'default',
},
}
)
);
function Button({
className,
@@ -41,19 +38,13 @@ function Button({
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
return <Comp data-slot='button' className={cn(buttonVariants({ variant, size, className }))} {...props} />;
}
export { Button, buttonVariants }
export { Button, buttonVariants };

165
src/components/ui/form.tsx Normal file
View File

@@ -0,0 +1,165 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -0,0 +1,183 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -1,5 +1,5 @@
import { useState, useCallback } from 'react';
import { ApiClient } from '../api-client';
import { ApiClient } from '@/lib/api-client';
// 创建API客户端实例
const apiClient = new ApiClient({

View File

@@ -1,8 +1,4 @@
import axios, {
type AxiosInstance,
type AxiosRequestConfig,
type AxiosResponse,
} from "axios";
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios';
export interface ApiClientConfig {
BASE: string;
@@ -21,13 +17,13 @@ export class ApiClient {
baseURL: config.BASE,
timeout: 10000,
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json',
},
});
// 请求拦截器
this.client.interceptors.request.use(
(config) => {
config => {
// 添加认证token
if (this.config.TOKEN) {
const token = this.config.TOKEN();
@@ -50,7 +46,7 @@ export class ApiClient {
(error: any) => {
// 统一错误处理
if (error.response) {
console.error("API错误:", error.response.data);
console.error('API错误:', error.response.data);
}
return Promise.reject(error);
}
@@ -64,21 +60,13 @@ export class ApiClient {
}
// POST请求
async post<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig
): Promise<T> {
async post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.post<T>(url, data, config);
return response.data;
}
// PUT请求
async put<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig
): Promise<T> {
async put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.put<T>(url, data, config);
return response.data;
}
@@ -90,11 +78,7 @@ export class ApiClient {
}
// PATCH请求
async patch<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig
): Promise<T> {
async patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.patch<T>(url, data, config);
return response.data;
}

21
src/lib/api.ts Normal file
View File

@@ -0,0 +1,21 @@
import { OpenAPI } from '@/api/core/OpenAPI';
import * as Service from '@/api/services/Service';
import * as ComfyService from '@/api/services/ComfyService';
import * as DefaultService from '@/api/services/DefaultService';
import * as WebhookService from '@/api/services/WebhookService';
const baseURL = (() => {
const url = import.meta.env.VITE_API_BASE_URL || '';
return url;
})();
OpenAPI.BASE = baseURL;
// 统一导出 api 实例(同步导入,保证 api 是同步对象)
export const api = {
...Service,
...ComfyService,
...DefaultService,
...WebhookService,
OpenAPI,
};

View File

@@ -1,116 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Service } from '@/api/services/Service';
import type { Body_create_model_api_models_create_post } from '@/api/models/Body_create_model_api_models_create_post';
import type { Body_update_model_api_models__model_id__put } from '@/api/models/Body_update_model_api_models__model_id__put';
import ModelForm from '@/components/model/ModelForm';
interface ModelItem {
id: number;
tag: Record<string, string>;
cover_url: string;
status: number;
create_time: string;
}
const ModelPage: React.FC = () => {
const [models, setModels] = useState<ModelItem[]>([]);
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState<ModelItem | null>(null);
const [open, setOpen] = useState(false);
const [editMode, setEditMode] = useState(false);
const fetchModels = async () => {
setLoading(true);
try {
const res = await Service.getModelsListApiModelsListGet({});
setModels(res.data || []);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchModels();
}, []);
const handleAdd = () => {
setEditMode(false);
setSelected(null);
setOpen(true);
};
const handleEdit = (item: ModelItem) => {
setEditMode(true);
setSelected(item);
setOpen(true);
};
const handleDelete = async (id: number) => {
await Service.deleteModelApiModelsModelIdDelete({ modelId: id });
fetchModels();
};
const handleFormSubmit = async (data: Body_create_model_api_models_create_post | Body_update_model_api_models__model_id__put, id?: number) => {
if (editMode && id) {
await Service.updateModelApiModelsModelIdPut({ modelId: id, formData: data as Body_update_model_api_models__model_id__put });
} else {
await Service.createModelApiModelsCreatePost({ formData: data as Body_create_model_api_models_create_post });
}
setOpen(false);
fetchModels();
};
return (
<div className="p-6">
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold"></h1>
<Button onClick={handleAdd}></Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{models.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.id}</TableCell>
<TableCell>{Object.values(item.tag).join('_')}</TableCell>
<TableCell>
<img src={item.cover_url} alt="cover" className="w-16 h-16 object-cover rounded" />
</TableCell>
<TableCell>{item.status === 1 ? '启用' : '禁用'}</TableCell>
<TableCell>{item.create_time}</TableCell>
<TableCell>
<Button size="sm" variant="outline" onClick={() => handleEdit(item)} className="mr-2"></Button>
<Button size="sm" variant="destructive" onClick={() => handleDelete(item.id)}></Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogTitle>{editMode ? '编辑模特' : '新建模特'}</DialogTitle>
<ModelForm
initialData={selected}
onSubmit={handleFormSubmit}
onCancel={() => setOpen(false)}
editMode={editMode}
/>
</DialogContent>
</Dialog>
</div>
);
};
export default ModelPage;

View File

@@ -0,0 +1,326 @@
import React, { useEffect, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { api } from '@/lib/api';
interface ModelFormProps {
initialData?: any;
onSubmit: (data: any, id?: number) => void;
onCancel: () => void;
editMode?: boolean;
}
const formSchema = z.object({
sex: z.string().min(1, '请选择性别'),
category: z.string().min(1, '请选择类别'),
size: z.string().min(1, '请选择尺寸'),
material: z.string().min(1, '请选择材质'),
color: z.string().min(1, '请选择颜色'),
cover: z.any().optional(),
status: z.boolean(),
});
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
sex: '',
category: '',
size: '',
material: '',
color: '',
cover: undefined,
status: true,
};
const ModelForm: React.FC<ModelFormProps> = ({ initialData, onSubmit, onCancel, editMode }) => {
const [tagOptions, setTagOptions] = React.useState<any[]>([]);
const [coverPreview, setCoverPreview] = React.useState<string | null>(null);
// 新增:用于触发文件选择的 ref
const fileInputRef = React.useRef<HTMLInputElement>(null);
// 初始化表单
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: initialData
? {
sex: initialData.tag?.sex || '',
category: initialData.tag?.category || '',
size: initialData.tag?.size || '',
material: initialData.tag?.material || '',
color: initialData.tag?.color || '',
cover: undefined,
status: initialData.status === 1 ? true : false,
}
: defaultValues,
});
// 支持初始图片预览
React.useEffect(() => {
if (initialData?.coverUrl) {
setCoverPreview(initialData.coverUrl);
}
}, [initialData]);
// 级联选择逻辑
const sexOptions = tagOptions;
const categoryOptions = useMemo(() => {
return form.watch('sex') ? tagOptions.find((t: any) => t.title === form.watch('sex'))?.children || [] : [];
}, [tagOptions, form.watch('sex')]);
const sizeOptions = useMemo(() => {
return form.watch('category') ? categoryOptions.find((t: any) => t.title === form.watch('category'))?.children || [] : [];
}, [categoryOptions, form.watch('category')]);
const materialOptions = useMemo(() => {
if (!form.watch('size')) return [];
const size = sizeOptions.find((t: any) => t.title === form.watch('size'));
return size?.children?.find((c: any) => c.title === '材质')?.children || [];
}, [sizeOptions, form.watch('size')]);
const colorOptions = useMemo(() => {
if (!form.watch('size')) return [];
const size = sizeOptions.find((t: any) => t.title === form.watch('size'));
return size?.children?.find((c: any) => c.title === '颜色')?.children || [];
}, [sizeOptions, form.watch('size')]);
// 拉取标签数据
useEffect(() => {
api.Service.fetchModelTagsApiTagModelTagListGet().then(data => setTagOptions(data.data || []));
}, []);
// 提交
const handleFormSubmit = async (values: FormValues) => {
const tagString = [values.sex, values.category, values.size, values.material, values.color].join('_');
onSubmit({ tags: tagString, cover_image: values.cover, status: values.status ? 1 : 0 }, initialData?.id);
};
// 级联联动清空下级
const handleCascade = (level: keyof FormValues, value: string) => {
if (level === 'sex') {
form.setValue('sex', value);
form.setValue('category', '');
form.setValue('size', '');
form.setValue('material', '');
form.setValue('color', '');
} else if (level === 'category') {
form.setValue('category', value);
form.setValue('size', '');
form.setValue('material', '');
form.setValue('color', '');
} else if (level === 'size') {
form.setValue('size', value);
form.setValue('material', '');
form.setValue('color', '');
} else if (level === 'material') {
form.setValue('material', value);
} else if (level === 'color') {
form.setValue('color', value);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleFormSubmit)} className='flex flex-col md:flex-row gap-8 items-start'>
{/* 左侧图片区,点击可上传 */}
<div className='w-full md:w-[240px] flex-shrink-0 flex flex-col items-center'>
<div
className='w-full aspect-[9/16] bg-muted flex items-center justify-center rounded-lg border border-muted shadow-md overflow-hidden relative group cursor-pointer'
onClick={() => fileInputRef.current?.click()}
tabIndex={0}
role='button'
aria-label='上传封面图片'
>
{coverPreview ? (
<img
src={coverPreview}
alt='预览'
className='object-contain w-full h-full transition-transform duration-200 hover:scale-105 bg-white'
style={{ background: '#f8f8f8' }}
/>
) : (
<span className='text-muted-foreground text-sm'></span>
)}
{/* 遮罩/按钮 */}
<div className='absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity pointer-events-none'>
<span className='text-white text-base font-medium pointer-events-none'></span>
</div>
{/* 隐藏的文件选择 */}
<input
ref={fileInputRef}
type='file'
accept='image/*'
className='hidden'
onChange={e => {
const file = e.target.files?.[0];
form.setValue('cover', file);
if (file) {
setCoverPreview(URL.createObjectURL(file));
} else {
setCoverPreview(null);
}
}}
/>
</div>
{/* 错误提示 */}
<div className='mt-2 w-full'>
<FormField control={form.control} name='cover' render={() => <FormMessage />} />
</div>
</div>
{/* 右侧表单区(去除封面图片项) */}
<div className='flex-1 w-full flex flex-col gap-4'>
{/* 性别 */}
<FormField
control={form.control}
name='sex'
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Select value={field.value} onValueChange={v => handleCascade('sex', v)}>
<SelectTrigger className='w-full'>
<SelectValue placeholder='请选择性别' />
</SelectTrigger>
<SelectContent>
{sexOptions.map((item: any) => (
<SelectItem key={item.title} value={item.title}>
{item.title}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* 类别 */}
<FormField
control={form.control}
name='category'
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Select value={field.value} onValueChange={v => handleCascade('category', v)} disabled={!form.watch('sex')}>
<SelectTrigger className='w-full'>
<SelectValue placeholder='请选择类别' />
</SelectTrigger>
<SelectContent>
{categoryOptions.map((item: any) => (
<SelectItem key={item.title} value={item.title}>
{item.title}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* 尺寸 */}
<FormField
control={form.control}
name='size'
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Select value={field.value} onValueChange={v => handleCascade('size', v)} disabled={!form.watch('category')}>
<SelectTrigger className='w-full'>
<SelectValue placeholder='请选择尺寸' />
</SelectTrigger>
<SelectContent>
{sizeOptions.map((item: any) => (
<SelectItem key={item.title} value={item.title}>
{item.title}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* 材质 */}
<FormField
control={form.control}
name='material'
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Select value={field.value} onValueChange={v => handleCascade('material', v)} disabled={!form.watch('size')}>
<SelectTrigger className='w-full'>
<SelectValue placeholder='请选择材质' />
</SelectTrigger>
<SelectContent>
{materialOptions.map((item: any) => (
<SelectItem key={item.title} value={item.title}>
{item.title}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* 颜色 */}
<FormField
control={form.control}
name='color'
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Select value={field.value} onValueChange={v => handleCascade('color', v)} disabled={!form.watch('size')}>
<SelectTrigger className='w-full'>
<SelectValue placeholder='请选择颜色' />
</SelectTrigger>
<SelectContent>
{colorOptions.map((item: any) => (
<SelectItem key={item.title} value={item.title}>
{item.title}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* 启用 */}
<FormField
control={form.control}
name='status'
render={({ field }) => (
<FormItem className='flex items-center space-x-2'>
<FormLabel htmlFor='status'></FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className='flex justify-end space-x-2 mt-6'>
<Button type='button' variant='outline' onClick={onCancel} disabled={form.formState.isSubmitting}>
</Button>
<Button type='submit' disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? '加载中...' : editMode ? '保存' : '创建'}
</Button>
</div>
</div>
</form>
</Form>
);
};
export default ModelForm;

View File

@@ -0,0 +1,111 @@
import React, { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import type { Body_create_model_api_models_create_post } from '@/api/models/Body_create_model_api_models_create_post';
import type { Body_update_model_api_models__model_id__put } from '@/api/models/Body_update_model_api_models__model_id__put';
import ModelForm from './components/ModelForm';
import { api } from '@/lib/api';
import { ConfirmDialog } from '@/components/block/confirm-dialog';
interface ModelItem {
id: number;
tag: Record<string, string>;
cover_url: string;
status: number;
create_time: string;
}
const ModelPage: React.FC = () => {
const [models, setModels] = useState<ModelItem[]>([]);
const [_, setLoading] = useState(false);
const [selected, setSelected] = useState<ModelItem | null>(null);
const [open, setOpen] = useState(false);
const [editMode, setEditMode] = useState(false);
const fetchModels = async () => {
setLoading(true);
try {
const res = await api.Service.getModelsListApiModelsListGet({});
setModels(res.data || []);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchModels();
}, []);
const handleAdd = () => {
setEditMode(false);
setSelected(null);
setOpen(true);
};
const handleEdit = (item: ModelItem) => {
setEditMode(true);
setSelected(item);
setOpen(true);
};
const handleDelete = async (id: number) => {
ConfirmDialog.confirm({
content: '确定要删除该模特吗?',
onConfirm: async () => {
await api.Service.deleteModelApiModelsModelIdDelete({ modelId: id });
fetchModels();
},
});
};
const handleFormSubmit = async (data: Body_create_model_api_models_create_post | Body_update_model_api_models__model_id__put, id?: number) => {
console.log(data);
if (editMode && id) {
await api.Service.updateModelApiModelsModelIdPut({ modelId: id, formData: data as Body_update_model_api_models__model_id__put });
} else {
await api.Service.createModelApiModelsCreatePost({ formData: data as Body_create_model_api_models_create_post });
}
setOpen(false);
fetchModels();
};
return (
<div className='p-6'>
<div className='flex justify-between items-center mb-4'>
<h1 className='text-2xl font-bold'></h1>
<Button onClick={handleAdd}></Button>
</div>
<div className='grid grid-cols-1 sm:grid-cols-3 lg:grid-cols-4 gap-6'>
{models.map(item => (
<div key={item.id} className='bg-white dark:bg-zinc-900 rounded-lg shadow p-4 flex flex-col'>
<img src={item.cover_url} alt='cover' className='w-full object-cover rounded mb-4 aspect-[9/16]' />
<div className='flex-1 flex flex-col gap-2'>
<div className='text-sm text-muted-foreground'>ID: {item.id}</div>
<div className='text-sm truncate'>: {Object.values(item.tag).join('_')}</div>
<div className='text-sm'>
: <span className={item.status === 1 ? 'text-green-600' : 'text-red-500'}>{item.status === 1 ? '启用' : '禁用'}</span>
</div>
<div className='text-xs text-muted-foreground'>: {item.create_time}</div>
</div>
<div className='mt-4 flex gap-2'>
<Button size='sm' variant='outline' onClick={() => handleEdit(item)} className='flex-1'>
</Button>
<Button size='sm' variant='destructive' onClick={() => handleDelete(item.id)} className='flex-1'>
</Button>
</div>
</div>
))}
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent style={{ width: '650px', maxWidth: '650px' }}>
<DialogTitle>{editMode ? '编辑模特' : '新建模特'}</DialogTitle>
<ModelForm initialData={selected} onSubmit={handleFormSubmit} onCancel={() => setOpen(false)} editMode={editMode} />
</DialogContent>
</Dialog>
</div>
);
};
export default ModelPage;

View File

@@ -1,16 +0,0 @@
import type { Config } from 'tailwindcss';
import animate from 'tailwindcss-animate';
export default {
darkMode: 'class',
content: ['./index.html', './src/**/*.{ts,tsx,js,jsx}'],
theme: {
extend: {
// 这里可以扩展自定义主题
},
},
plugins: [
animate,
// 其它shadcn/ui推荐插件
],
} satisfies Config;