Compare commits

...

54 Commits

Author SHA1 Message Date
534446931d 更新swag 2025-12-07 18:08:22 +08:00
1f06688237 优化展示 2025-12-02 17:11:02 +08:00
d97a55a992 优化展示 2025-12-02 16:20:46 +08:00
e0729ce600 支持ai点评 2025-12-02 16:00:58 +08:00
3ace1a35ee 更新swag 2025-12-02 14:39:50 +08:00
2734306690 修bug 2025-11-28 16:16:40 +08:00
543adc1ad6 修bug 2025-11-28 16:15:26 +08:00
7bcd8fb873 修bug 2025-11-28 14:08:29 +08:00
ae87ddb56d 修bug 2025-11-28 14:04:57 +08:00
e9545c9be1 优化展示 2025-11-27 22:01:49 +08:00
e1f38fd995 优化展示 2025-11-27 21:32:21 +08:00
2649ed048a 增加库存优先型一键生成配方 2025-11-27 21:24:42 +08:00
b368c172c5 更新swag 2025-11-27 21:16:55 +08:00
93f66d844c 优化展示 2025-11-27 20:49:05 +08:00
7166d5049f 优化展示 2025-11-27 20:45:34 +08:00
908af8eaa5 优化展示 2025-11-27 20:40:01 +08:00
091ba71069 比对配方 2025-11-27 20:35:16 +08:00
0985744184 优化展示 2025-11-27 18:39:48 +08:00
eec7e64e7d 修bug 2025-11-27 18:36:09 +08:00
01db083b8e 查看操作历史 2025-11-27 18:35:03 +08:00
1ddd3f8c90 优化展示 2025-11-27 18:29:05 +08:00
aa50fdc9de 优化展示 2025-11-27 18:27:31 +08:00
24344e380a 指定变更类型 2025-11-27 18:22:28 +08:00
19d4dd64d4 增加操作类型 2025-11-27 18:03:20 +08:00
ef82203ed7 更新swag 2025-11-27 18:02:01 +08:00
24d9b07c97 更新swag 2025-11-27 17:55:40 +08:00
ede3d6b330 自适应重量单位 2025-11-27 17:52:19 +08:00
9af7e0d005 调整库存按钮 2025-11-27 17:44:16 +08:00
6507b3ee14 库存管理界面 2025-11-27 17:37:05 +08:00
2a38cf5bc0 更新swag 2025-11-27 17:33:43 +08:00
80ab64e428 优化展示 2025-11-27 16:41:24 +08:00
fded64ef8f 优化展示 2025-11-27 16:27:59 +08:00
6e2da3f3c2 优化展示 2025-11-27 15:50:37 +08:00
ba96470c8e 修复bug 2025-11-26 22:23:10 +08:00
5fb1808943 实现一键生成配方 2025-11-26 22:06:12 +08:00
17ae47fa68 更新swag 2025-11-26 20:51:22 +08:00
6387a5798b 优化展示 2025-11-25 22:37:00 +08:00
cc9a85e85a 屏蔽 ResizeObserver 报错 2025-11-25 22:22:35 +08:00
cb4da5effa 增加参考价格 2025-11-25 20:38:51 +08:00
dd243ad2e7 更新swagger 2025-11-25 20:28:32 +08:00
018f736d2e 优化展示 2025-11-24 15:14:54 +08:00
985f84ad79 支持编辑配方 2025-11-24 15:11:42 +08:00
3505fcb250 优化展示 2025-11-24 14:52:12 +08:00
f8f91f0681 支持查看原料详情 2025-11-24 14:37:09 +08:00
f209d131a9 配方管理界面 2025-11-24 14:15:54 +08:00
3158115865 更新swag 2025-11-24 14:01:55 +08:00
72ea3fe634 更新swag 2025-11-24 13:44:41 +08:00
cd27d2242d 更新swag 2025-11-23 14:58:53 +08:00
eb55f170fa 优化展示 2025-11-22 19:39:35 +08:00
f7f95b4241 实现功能 2025-11-22 19:30:24 +08:00
55a3dbd1d5 猪类型增删改查界面 2025-11-22 19:22:21 +08:00
f44dc710c9 优化展示 2025-11-22 18:36:02 +08:00
c0e33ad429 支持修改营养需求 2025-11-22 18:30:01 +08:00
9761bf1dbb 更新swag 2025-11-22 18:01:32 +08:00
34 changed files with 4784 additions and 388 deletions

File diff suppressed because it is too large Load Diff

62
node_modules/.package-lock.json generated vendored
View File

@@ -46,6 +46,7 @@
"resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.28.4.tgz",
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -1908,6 +1909,7 @@
"version": "4.17.12",
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"peer": true,
"dependencies": {
"@types/lodash": "*"
}
@@ -2063,6 +2065,7 @@
"resolved": "https://registry.npmmirror.com/@vue/cli-service/-/cli-service-5.0.9.tgz",
"integrity": "sha512-yTX7GVyM19tEbd+y5/gA6MkVKA6K61nVYHYAivD61Hx6odVFmQsaC3/R3cWAHM1P5oVKCevBbumPljbT+tFG2w==",
"dev": true,
"peer": true,
"dependencies": {
"@babel/helper-compilation-targets": "^7.12.16",
"@soda/friendly-errors-webpack-plugin": "^1.8.0",
@@ -2747,6 +2750,7 @@
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2801,6 +2805,7 @@
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -3268,6 +3273,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741",
@@ -3878,6 +3884,7 @@
"resolved": "https://registry.npmmirror.com/css-loader/-/css-loader-6.11.0.tgz",
"integrity": "sha512-CTJ+AEQJjq5NzLga5pE39qdiSV56F8ywCIsqNIRF0r7BDgWsN25aazToqAFg7ZrtA/U016xudB3ffgweORxX7g==",
"dev": true,
"peer": true,
"dependencies": {
"icss-utils": "^5.1.0",
"postcss": "^8.4.33",
@@ -4632,6 +4639,7 @@
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -5292,6 +5300,21 @@
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"dev": true
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"ideallyInert": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
@@ -6425,12 +6448,14 @@
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"peer": true
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"peer": true
},
"node_modules/lodash-unified": {
"version": "1.0.3",
@@ -6660,6 +6685,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "17.0.1",
"resolved": "https://registry.npmmirror.com/marked/-/marked-17.0.1.tgz",
"integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -7571,6 +7608,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -8794,6 +8832,7 @@
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -9676,20 +9715,6 @@
"node": ">= 0.6"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"optional": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.12.0",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.12.0.tgz",
@@ -9846,6 +9871,7 @@
"version": "3.5.21",
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.21.tgz",
"integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.21",
"@vue/compiler-sfc": "3.5.21",
@@ -9941,6 +9967,7 @@
"resolved": "https://registry.npmmirror.com/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz",
"integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==",
"dev": true,
"peer": true,
"dependencies": {
"de-indent": "^1.0.2",
"he": "^1.2.0"
@@ -9999,6 +10026,7 @@
"resolved": "https://registry.npmmirror.com/webpack/-/webpack-5.101.3.tgz",
"integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==",
"dev": true,
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
@@ -10096,6 +10124,7 @@
"resolved": "https://registry.npmmirror.com/webpack-cli/-/webpack-cli-5.1.4.tgz",
"integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==",
"dev": true,
"peer": true,
"dependencies": {
"@discoveryjs/json-ext": "^0.5.0",
"@webpack-cli/configtest": "^2.1.1",
@@ -10267,6 +10296,7 @@
"resolved": "https://registry.npmmirror.com/webpack-sources/-/webpack-sources-3.3.3.tgz",
"integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==",
"dev": true,
"peer": true,
"engines": {
"node": ">=10.13.0"
}

48
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"axios": "^1.6.0",
"cron-parser": "^5.4.0",
"element-plus": "^2.4.0",
"marked": "^17.0.1",
"vue": "^3.4.0",
"vue-router": "^4.2.0",
"vue3-cron-plus-picker": "^1.0.2"
@@ -74,6 +75,7 @@
"resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.28.4.tgz",
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -1936,6 +1938,7 @@
"version": "4.17.12",
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"peer": true,
"dependencies": {
"@types/lodash": "*"
}
@@ -2091,6 +2094,7 @@
"resolved": "https://registry.npmmirror.com/@vue/cli-service/-/cli-service-5.0.9.tgz",
"integrity": "sha512-yTX7GVyM19tEbd+y5/gA6MkVKA6K61nVYHYAivD61Hx6odVFmQsaC3/R3cWAHM1P5oVKCevBbumPljbT+tFG2w==",
"dev": true,
"peer": true,
"dependencies": {
"@babel/helper-compilation-targets": "^7.12.16",
"@soda/friendly-errors-webpack-plugin": "^1.8.0",
@@ -2775,6 +2779,7 @@
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2829,6 +2834,7 @@
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -3296,6 +3302,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741",
@@ -3906,6 +3913,7 @@
"resolved": "https://registry.npmmirror.com/css-loader/-/css-loader-6.11.0.tgz",
"integrity": "sha512-CTJ+AEQJjq5NzLga5pE39qdiSV56F8ywCIsqNIRF0r7BDgWsN25aazToqAFg7ZrtA/U016xudB3ffgweORxX7g==",
"dev": true,
"peer": true,
"dependencies": {
"icss-utils": "^5.1.0",
"postcss": "^8.4.33",
@@ -4660,6 +4668,7 @@
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -6467,12 +6476,14 @@
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"peer": true
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"peer": true
},
"node_modules/lodash-unified": {
"version": "1.0.3",
@@ -6702,6 +6713,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "17.0.1",
"resolved": "https://registry.npmmirror.com/marked/-/marked-17.0.1.tgz",
"integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -7613,6 +7636,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -8836,6 +8860,7 @@
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -9718,20 +9743,6 @@
"node": ">= 0.6"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"optional": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.12.0",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.12.0.tgz",
@@ -9888,6 +9899,7 @@
"version": "3.5.21",
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.21.tgz",
"integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.21",
"@vue/compiler-sfc": "3.5.21",
@@ -9983,6 +9995,7 @@
"resolved": "https://registry.npmmirror.com/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz",
"integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==",
"dev": true,
"peer": true,
"dependencies": {
"de-indent": "^1.0.2",
"he": "^1.2.0"
@@ -10041,6 +10054,7 @@
"resolved": "https://registry.npmmirror.com/webpack/-/webpack-5.101.3.tgz",
"integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==",
"dev": true,
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
@@ -10138,6 +10152,7 @@
"resolved": "https://registry.npmmirror.com/webpack-cli/-/webpack-cli-5.1.4.tgz",
"integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==",
"dev": true,
"peer": true,
"dependencies": {
"@discoveryjs/json-ext": "^0.5.0",
"@webpack-cli/configtest": "^2.1.1",
@@ -10309,6 +10324,7 @@
"resolved": "https://registry.npmmirror.com/webpack-sources/-/webpack-sources-3.3.3.tgz",
"integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==",
"dev": true,
"peer": true,
"engines": {
"node": ">=10.13.0"
}

View File

@@ -20,6 +20,7 @@
"axios": "^1.6.0",
"cron-parser": "^5.4.0",
"element-plus": "^2.4.0",
"marked": "^17.0.1",
"vue": "^3.4.0",
"vue-router": "^4.2.0",
"vue3-cron-plus-picker": "^1.0.2"

View File

@@ -8,6 +8,7 @@ import { Response } from '../enums';
* @property {string} network_id
* @property {string} location
* @property {string} status
* @property {string} firmware_version
* @property {object} properties
* @property {string} created_at
* @property {string} updated_at
@@ -29,6 +30,18 @@ import { Response } from '../enums';
* @property {object} [properties]
*/
/**
* @typedef {object} OtaUpgradeResponse
* @property {number} task_id - OTA 升级任务ID
*/
/**
* @typedef {object} OtaUpgradeProgressResponse
* @property {number} task_id - OTA 升级任务ID
* @property {string} current_stage - 当前阶段
* @property {string} message - 状态消息
*/
/**
* 获取系统中所有区域主控的列表
* @returns {Promise<Array<AreaControllerResponse>>}
@@ -74,10 +87,47 @@ export const deleteAreaController = (id) => {
return http.delete(`/api/v1/area-controllers/${id}`);
};
/**
* 为指定的区域主控上传固件并启动一个OTA升级任务
* @param {string} id - 区域主控ID
* @param {File} firmwareFile - 固件压缩包文件
* @returns {Promise<OtaUpgradeResponse>}
*/
export const startOtaUpdate = (id, firmwareFile) => {
const formData = new FormData();
formData.append('firmware_file', firmwareFile);
return http.post(`/api/v1/area-controllers/${id}/ota/start`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
};
/**
* 根据任务ID查询指定OTA升级任务的当前进度
* @param {string} taskId - OTA任务ID
* @returns {Promise<OtaUpgradeProgressResponse>}
*/
export const getOtaProgress = (taskId) => {
return http.get(`/api/v1/area-controllers/ota/progress/${taskId}`);
};
/**
* 根据任务ID请求停止一个正在进行的OTA升级任务
* @param {string} taskId - OTA任务ID
* @returns {Promise<Response>}
*/
export const stopOtaTask = (taskId) => {
return http.post(`/api/v1/area-controllers/ota/tasks/${taskId}/stop`);
};
export const AreaControllerApi = {
list: getAreaControllers,
create: createAreaController,
getById: getAreaControllerById,
update: updateAreaController,
delete: deleteAreaController,
startOta: startOtaUpdate,
getOtaProgress: getOtaProgress,
stopOta: stopOtaTask,
};

View File

@@ -3,7 +3,14 @@ import {PaginationDTO, Response} from '../enums';
// --- Typedefs for Feed Management ---
// --- NutrientRawMaterial ---
// --- Nutrient ---
/**
* @typedef {object} NutrientRawMaterialDTO
* @property {number} id - 原料ID
* @property {string} name - 原料名称
* @property {number} value - 该原料中此营养素的含量
*/
/**
* @typedef {object} NutrientResponse
@@ -13,13 +20,6 @@ import {PaginationDTO, Response} from '../enums';
* @property {Array<NutrientRawMaterialDTO>} raw_materials - 包含此营养的原料列表
*/
/**
* @typedef {object} NutrientRawMaterialDTO
* @property {number} id - 原料ID
* @property {string} name - 原料名称
* @property {number} value - 该原料中此营养素的含量
*/
/**
* @typedef {object} ListNutrientResponse
* @property {Array<NutrientResponse>} list
@@ -239,6 +239,8 @@ import {PaginationDTO, Response} from '../enums';
* @property {string} name
* @property {string} description
* @property {Array<RawMaterialNutrientDTO>} raw_material_nutrients
* @property {number} [reference_price] - 参考价格(kg/元)
* @property {number} [max_addition_ratio] - 最大添加比例(%)
*/
/**
@@ -251,6 +253,8 @@ import {PaginationDTO, Response} from '../enums';
* @typedef {object} RawMaterialsParams
* @property {string} [name] - 按原料名称模糊查询
* @property {string} [nutrient_name] - 按营养名称模糊查询
* @property {number} [max_reference_price] - 参考价格最大值
* @property {number} [min_reference_price] - 参考价格最小值
* @property {string} [order_by] - 排序字段,例如 "id DESC"
* @property {number} [page]
* @property {number} [page_size]
@@ -260,275 +264,80 @@ import {PaginationDTO, Response} from '../enums';
* @typedef {object} CreateRawMaterialRequest
* @property {string} name - 原料名称
* @property {string} [description] - 描述
* @property {number} [reference_price] - 参考价格(kg/元)
* @property {number} [max_addition_ratio] - 最大添加比例(%)
*/
/**
* @typedef {object} UpdateRawMaterialRequest
* @property {string} name - 原料名称
* @property {string} [description] - 描述
* @property {number} [reference_price] - 参考价格(kg/元)
* @property {number} [max_addition_ratio] - 最大添加比例(%)
*/
// --- API Functions ---
// --- Nutrient ---
// --- Recipe ---
/**
* 获取营养种类列表
* @param {NutrientsParams} params - 查询参数
* @returns {Promise<Response<ListNutrientResponse>>}
*/
export const getNutrients = (params) => {
return http.get('/api/v1/feed/nutrients', {params});
};
/**
* 创建营养种类
* @param {CreateNutrientRequest} data - 请求体
* @returns {Promise<Response<NutrientResponse>>}
*/
export const createNutrient = (data) => {
return http.post('/api/v1/feed/nutrients', data);
};
/**
* 获取营养种类详情
* @param {number} id - 营养种类ID
* @returns {Promise<Response<NutrientResponse>>}
*/
export const getNutrientById = (id) => {
return http.get(`/api/v1/feed/nutrients/${id}`);
};
/**
* 更新营养种类
* @param {number} id - 营养种类ID
* @param {UpdateNutrientRequest} data - 请求体
* @returns {Promise<Response<NutrientResponse>>}
*/
export const updateNutrient = (id, data) => {
return http.put(`/api/v1/feed/nutrients/${id}`, data);
};
/**
* 删除营养种类
* @param {number} id - 营养种类ID
* @returns {Promise<Response>}
*/
export const deleteNutrient = (id) => {
return http.delete(`/api/v1/feed/nutrients/${id}`);
};
// --- PigAgeStage ---
/**
* 获取猪年龄阶段列表
* @param {PigAgeStagesParams} params - 查询参数
* @returns {Promise<Response<ListPigAgeStageResponse>>}
*/
export const getPigAgeStages = (params) => {
return http.get('/api/v1/feed/pig-age-stages', {params});
};
/**
* 创建猪年龄阶段
* @param {CreatePigAgeStageRequest} data - 请求体
* @returns {Promise<Response<PigAgeStageResponse>>}
*/
export const createPigAgeStage = (data) => {
return http.post('/api/v1/feed/pig-age-stages', data);
};
/**
* 获取猪年龄阶段详情
* @param {number} id - 猪年龄阶段ID
* @returns {Promise<Response<PigAgeStageResponse>>}
*/
export const getPigAgeStageById = (id) => {
return http.get(`/api/v1/feed/pig-age-stages/${id}`);
};
/**
* 更新猪年龄阶段
* @param {number} id - 猪年龄阶段ID
* @param {UpdatePigAgeStageRequest} data - 请求体
* @returns {Promise<Response<PigAgeStageResponse>>}
*/
export const updatePigAgeStage = (id, data) => {
return http.put(`/api/v1/feed/pig-age-stages/${id}`, data);
};
/**
* 删除猪年龄阶段
* @param {number} id - 猪年龄阶段ID
* @returns {Promise<Response>}
*/
export const deletePigAgeStage = (id) => {
return http.delete(`/api/v1/feed/pig-age-stages/${id}`);
};
// --- PigBreed ---
/**
* 获取猪品种列表
* @param {PigBreedsParams} params - 查询参数
* @returns {Promise<Response<ListPigBreedResponse>>}
*/
export const getPigBreeds = (params) => {
return http.get('/api/v1/feed/pig-breeds', {params});
};
/**
* 创建猪品种
* @param {CreatePigBreedRequest} data - 请求体
* @returns {Promise<Response<PigBreedResponse>>}
*/
export const createPigBreed = (data) => {
return http.post('/api/v1/feed/pig-breeds', data);
};
/**
* 获取猪品种详情
* @param {number} id - 猪品种ID
* @returns {Promise<Response<PigBreedResponse>>}
*/
export const getPigBreedById = (id) => {
return http.get(`/api/v1/feed/pig-breeds/${id}`);
};
/**
* 更新猪品种
* @param {number} id - 猪品种ID
* @param {UpdatePigBreedRequest} data - 请求体
* @returns {Promise<Response<PigBreedResponse>>}
*/
export const updatePigBreed = (id, data) => {
return http.put(`/api/v1/feed/pig-breeds/${id}`, data);
};
/**
* 删除猪品种
* @param {number} id - 猪品种ID
* @returns {Promise<Response>}
*/
export const deletePigBreed = (id) => {
return http.delete(`/api/v1/feed/pig-breeds/${id}`);
};
// --- PigType ---
/**
* 获取猪类型列表
* @param {PigTypesParams} params - 查询参数
* @returns {Promise<Response<ListPigTypeResponse>>}
*/
export const getPigTypes = (params) => {
return http.get('/api/v1/feed/pig-types', {params});
};
/**
* 创建猪类型
* @param {CreatePigTypeRequest} data - 请求体
* @returns {Promise<Response<PigTypeResponse>>}
*/
export const createPigType = (data) => {
return http.post('/api/v1/feed/pig-types', data);
};
/**
* 获取猪类型详情
* @param {number} id - 猪类型ID
* @returns {Promise<Response<PigTypeResponse>>}
*/
export const getPigTypeById = (id) => {
return http.get(`/api/v1/feed/pig-types/${id}`);
};
/**
* 更新猪类型
* @param {number} id - 猪类型ID
* @param {UpdatePigTypeRequest} data - 请求体
* @returns {Promise<Response<PigTypeResponse>>}
*/
export const updatePigType = (id, data) => {
return http.put(`/api/v1/feed/pig-types/${id}`, data);
};
/**
* 删除猪类型
* @param {number} id - 猪类型ID
* @returns {Promise<Response>}
*/
export const deletePigType = (id) => {
return http.delete(`/api/v1/feed/pig-types/${id}`);
};
/**
* 全量更新猪类型的营养需求
* @param {number} id - 猪类型ID
* @param {UpdatePigTypeNutrientRequirementsRequest} data - 新的营养需求列表
* @returns {Promise<Response<PigTypeResponse>>}
*/
export const updatePigTypeNutrientRequirements = (id, data) => {
return http.put(`/api/v1/feed/pig-types/${id}/nutrient-requirements`, data);
};
// --- RawMaterial ---
/**
* @typedef {object} RawMaterialNutrientItem
* @property {number} nutrient_id - 营养素ID
* @property {number} value - 含量值必须大于等于0
* @typedef {object} RecipeIngredientDto
* @property {number} raw_material_id - 原料ID
* @property {number} [percentage] - 原料在配方中的百分比 (0-100之间)
*/
/**
* @typedef {object} UpdateRawMaterialNutrientsRequest
* @property {Array<RawMaterialNutrientItem>} nutrients
*/
/**
* @typedef {object} RawMaterialNutrientDTO
* @property {number} id
* @property {number} nutrient_id
* @property {string} nutrient_name
* @property {number} value
*/
/**
* @typedef {object} RawMaterialResponse
* @typedef {object} RecipeResponse
* @property {number} id
* @property {string} name
* @property {string} description
* @property {Array<RawMaterialNutrientDTO>} raw_material_nutrients
* @property {Array<RecipeIngredientDto>} recipe_ingredients
*/
/**
* @typedef {object} ListRawMaterialResponse
* @property {Array<RawMaterialResponse>} list
* @typedef {object} ListRecipeResponse
* @property {Array<RecipeResponse>} list
* @property {PaginationDTO} pagination
*/
/**
* @typedef {object} RawMaterialsParams
* @property {string} [name] - 按原料名称模糊查询
* @property {string} [nutrient_name] - 按营养名称模糊查询
* @typedef {object} RecipesParams
* @property {string} [name] - 按名称模糊查询
* @property {string} [order_by] - 排序字段,例如 "id DESC"
* @property {number} [page]
* @property {number} [page_size]
*/
/**
* @typedef {object} CreateRawMaterialRequest
* @property {string} name - 原料名称
* @property {string} [description] - 描述
* @typedef {object} CreateRecipeRequest
* @property {string} name - 配方名称
* @property {string} [description] - 配方描述
* @property {Array<RecipeIngredientDto>} [recipe_ingredients] - 配方原料组成
*/
/**
* @typedef {object} UpdateRawMaterialRequest
* @property {string} name - 原料名称
* @property {string} [description] - 描述
* @typedef {object} UpdateRecipeRequest
* @property {string} name - 配方名称
* @property {string} [description] - 配方描述
* @property {Array<RecipeIngredientDto>} [recipe_ingredients] - 配方原料组成
*/
/**
* @typedef {object} GenerateRecipeResponse
* @property {number} id - 新生成的配方ID
* @property {string} name - 新生成的配方名称
* @property {string} description - 新生成的配方描述
*/
/**
* @typedef {string} AIModel - AI模型枚举
* @enum {string}
* @property {"Gemini"} Gemini - Gemini模型
*/
/**
* @typedef {object} ReviewRecipeResponse
* @property {AIModel} ai_model - 使用的 AI 模型
* @property {string} review_message - 点评内容
*/
// --- API Functions ---
@@ -792,6 +601,82 @@ export const updateRawMaterialNutrients = (id, data) => {
return http.put(`/api/v1/feed/raw-materials/${id}/nutrients`, data);
};
// --- Recipe ---
/**
* 获取配方列表
* @param {RecipesParams} params - 查询参数
* @returns {Promise<Response<ListRecipeResponse>>}
*/
export const getRecipes = (params) => {
return http.get('/api/v1/feed/recipes', {params});
};
/**
* 创建配方
* @param {CreateRecipeRequest} data - 请求体
* @returns {Promise<Response<RecipeResponse>>}
*/
export const createRecipe = (data) => {
return http.post('/api/v1/feed/recipes', data);
};
/**
* 获取配方详情
* @param {number} id - 配方ID
* @returns {Promise<Response<RecipeResponse>>}
*/
export const getRecipeById = (id) => {
return http.get(`/api/v1/feed/recipes/${id}`);
};
/**
* 更新配方
* @param {number} id - 配方ID
* @param {UpdateRecipeRequest} data - 请求体
* @returns {Promise<Response<RecipeResponse>>}
*/
export const updateRecipe = (id, data) => {
return http.put(`/api/v1/feed/recipes/${id}`, data);
};
/**
* 删除配方
* @param {number} id - 配方ID
* @returns {Promise<Response>}
*/
export const deleteRecipe = (id) => {
return http.delete(`/api/v1/feed/recipes/${id}`);
};
/**
* 使用系统中所有可用的原料一键生成配方
* @param {number} pigTypeId - 猪类型ID
* @returns {Promise<Response<GenerateRecipeResponse>>}
*/
export const generateRecipeFromAllMaterials = (pigTypeId) => {
return http.post(`/api/v1/feed/recipes/generate-from-all-materials/${pigTypeId}`);
};
/**
* 根据指定的猪类型ID优先使用有库存的原料自动计算并创建一个配方。
* @param {number} pigTypeId - 猪类型ID
* @returns {Promise<Response<GenerateRecipeResponse>>}
*/
export const generatePrioritizedStockRecipe = (pigTypeId) => {
return http.post(`/api/v1/feed/recipes/generate-prioritized-stock/${pigTypeId}`);
};
/**
* 使用AI对指定配方进行点评并针对目标猪类型给出建议。
* @param {number} id - 配方ID
* @param {number} pigTypeId - 猪类型ID
* @returns {Promise<Response<ReviewRecipeResponse>>}
*/
export const aiDiagnoseRecipe = (id, pigTypeId) => {
return http.get(`/api/v1/feed/recipes/${id}/ai-diagnose`, {params: {pig_type_id: pigTypeId}, timeout: 0});
};
export const FeedApi = {
getNutrients,
@@ -814,11 +699,19 @@ export const FeedApi = {
getPigTypeById,
updatePigType,
deletePigType,
updatePigTypeNutrientRequirements, // 新增的 API 方法
updatePigTypeNutrientRequirements,
getRawMaterials,
createRawMaterial,
getRawMaterialById,
updateRawMaterial,
deleteRawMaterial,
updateRawMaterialNutrients,
getRecipes,
createRecipe,
getRecipeById,
updateRecipe,
deleteRecipe,
generateRecipeFromAllMaterials,
generatePrioritizedStockRecipe,
aiDiagnoseRecipe,
};

View File

@@ -5,6 +5,7 @@ import { AlarmApi } from './alarm.js'; // 导入告警API
import { HealthApi } from './health.js'; // 导入健康检查API
import { DeviceTemplateApi } from './deviceTemplate.js'; // 导入设备模板API
import { FeedApi } from './feed.js'; // 导入饲料管理API
import { InventoryApi } from './inventory.js'; // 导入库存管理API
/**
* API客户端
@@ -19,6 +20,7 @@ export class ApiClient {
this.alarms = AlarmApi; // 添加告警API
this.deviceTemplates = DeviceTemplateApi; // 添加设备模板API
this.feeds = FeedApi; // 添加饲料管理API
this.inventory = InventoryApi; // 添加库存管理API
}
}

108
src/api/inventory.js Normal file
View File

@@ -0,0 +1,108 @@
import http from '../utils/http';
import { PaginationDTO, Response, StockLogSourceType } from '../enums';
// --- Typedefs for Inventory Management ---
/**
* @typedef {object} StockAdjustmentRequest
* @property {number} change_amount - 变动数量, 正数为入库, 负数为出库, 单位: g
* @property {number} raw_material_id - 要调整的原料ID
* @property {StockLogSourceType} source_type - 库存变动来源类型
* @property {number} [source_id] - 来源ID, 例如: 配方ID, 采购单ID等
* @property {string} [remarks] - 备注
*/
/**
* @typedef {object} StockLogResponse
* @property {number} after_quantity
* @property {number} before_quantity
* @property {number} change_amount
* @property {string} happened_at
* @property {number} id
* @property {number} raw_material_id
* @property {string} raw_material_name
* @property {string} remarks
* @property {number} source_id
* @property {StockLogSourceType} source_type
*/
/**
* @typedef {object} ListStockLogResponse
* @property {Array<StockLogResponse>} list
* @property {PaginationDTO} pagination
*/
/**
* @typedef {object} CurrentStockResponse
* @property {string} last_updated - 最后更新时间
* @property {number} raw_material_id - 原料ID
* @property {string} raw_material_name - 原料名称
* @property {number} stock - 当前库存量, 单位: g
* @property {StockLogSourceType} [last_operation_source_type] - 上次库存变动的来源类型
*/
/**
* @typedef {object} ListCurrentStockResponse
* @property {Array<CurrentStockResponse>} list
* @property {PaginationDTO} pagination
*/
/**
* @typedef {object} AdjustStockParams
* @property {StockAdjustmentRequest} request - 库存调整请求
*/
/**
* @typedef {object} GetCurrentStockListParams
* @property {boolean} [has_stock] - 只查询有库存的原料
* @property {string} [order_by] - 排序字段, 例如 "stock DESC"
* @property {number} [page] - 页码
* @property {number} [page_size] - 每页数量
* @property {string} [raw_material_name] - 按原料名称模糊查询
*/
/**
* @typedef {object} GetStockLogListParams
* @property {string} [end_time] - 结束时间 (RFC3339格式)
* @property {string} [order_by] - 排序字段
* @property {number} [page] - 页码
* @property {number} [page_size] - 每页数量
* @property {number} [raw_material_id] - 按原料ID精确查询
* @property {Array<StockLogSourceType>} [source_types] - 按来源类型查询
* @property {string} [start_time] - 开始时间 (RFC3339格式, e.g., "2023-01-01T00:00:00Z")
*/
// --- API Functions ---
/**
* 调整原料库存
* @param {StockAdjustmentRequest} data - 库存调整请求
* @returns {Promise<Response<StockLogResponse>>}
*/
export const adjustStock = (data) => {
return http.post('/api/v1/inventory/stock/adjust', data);
};
/**
* 获取当前库存列表
* @param {GetCurrentStockListParams} params - 查询参数
* @returns {Promise<Response<ListCurrentStockResponse>>}
*/
export const getCurrentStockList = (params) => {
return http.get('/api/v1/inventory/stock/current', { params });
};
/**
* 获取库存变动日志
* @param {GetStockLogListParams} params - 查询参数
* @returns {Promise<Response<ListStockLogResponse>>}
*/
export const getStockLogList = (params) => {
return http.get('/api/v1/inventory/stock/logs', { params });
};
export const InventoryApi = {
adjustStock,
getCurrentStockList,
getStockLogList,
};

View File

@@ -0,0 +1,283 @@
<template>
<el-dialog
v-model="dialogVisible"
title="AI点评配方"
width="80%"
:before-close="handleClose"
destroy-on-close
align-center
>
<el-form label-width="100px">
<el-form-item label="配方名称:">
<span>{{ recipe ? recipe.name : 'N/A' }}</span>
</el-form-item>
<el-form-item label="目标猪类型:">
<el-select
v-model="selectedPigTypeId"
placeholder="请选择目标猪类型"
filterable
:loading="loadingPigTypes"
:disabled="loadingAIReview"
style="width: 100%;"
>
<el-option
v-for="item in pigTypes"
:key="item.id"
:label="item.label"
:value="item.id"
></el-option>
</el-select>
</el-form-item>
</el-form>
<!-- 将AI点评结果移出el-form -->
<div v-if="aiReviewResult || error" style="margin-top: 20px;">
<label class="el-form-item__label" style="width: 100px;">AI点评结果:</label>
<div class="ai-review-content-wrapper" style="margin-top: 10px;">
<el-alert
v-if="error"
:title="error"
type="error"
show-icon
:closable="false"
></el-alert>
<div v-else-if="aiReviewResult" class="ai-review-content">
<p style="text-align: center; margin-bottom: 20px; color: #888;">
<strong>{{ aiReviewResult.ai_model }}</strong> 生成
</p>
<div v-html="renderedReview" class="markdown-body"></div>
</div>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose" :disabled="loadingAIReview">取消</el-button>
<el-button
type="primary"
@click="handleConfirm"
:loading="loadingAIReview"
:disabled="!selectedPigTypeId || loadingPigTypes"
>
{{ aiReviewResult ? '重新点评' : '开始点评' }}
</el-button>
</span>
</template>
</el-dialog>
</template>
<script>
import { getPigTypes, aiDiagnoseRecipe } from '../../api/feed';
import { ElMessage } from 'element-plus';
import { marked } from 'marked';
export default {
name: 'AIRecipeReviewDialog',
props: {
visible: {
type: Boolean,
default: false,
},
recipe: {
type: Object,
default: null,
},
},
emits: ['update:visible', 'cancel'],
data() {
return {
dialogVisible: this.visible,
pigTypes: [], // 格式化后的猪类型列表
selectedPigTypeId: null,
aiReviewResult: null,
loadingPigTypes: false,
loadingAIReview: false,
error: null,
};
},
computed: {
renderedReview() {
if (this.aiReviewResult && this.aiReviewResult.review_message) {
return marked(this.aiReviewResult.review_message);
}
return '';
},
},
watch: {
visible(newVal) {
this.dialogVisible = newVal;
if (newVal) {
this.resetState();
this.loadPigTypes();
}
},
dialogVisible(newVal) {
this.$emit('update:visible', newVal);
},
},
methods: {
/**
* 重置组件内部状态
*/
resetState() {
this.selectedPigTypeId = null;
this.aiReviewResult = null;
this.error = null;
this.pigTypes = [];
},
/**
* 加载猪类型列表
*/
async loadPigTypes() {
this.loadingPigTypes = true;
this.error = null;
try {
// 获取所有猪类型,假设一次性获取足够的数据
const response = await getPigTypes({ page: 1, page_size: 1000 });
if (response.code === 2000 && response.data && response.data.list) {
this.pigTypes = response.data.list.map(type => ({
id: type.id,
label: `${type.breed_name} - ${type.age_stage_name}`,
}));
} else {
this.error = response.msg || '获取猪类型失败';
ElMessage.error(this.error);
}
} catch (err) {
this.error = '加载猪类型失败: ' + (err.message || '未知错误');
ElMessage.error(this.error);
console.error('加载猪类型失败:', err);
} finally {
this.loadingPigTypes = false;
}
},
/**
* 处理确认按钮点击调用AI点评接口
*/
async handleConfirm() {
if (!this.recipe || !this.recipe.id) {
ElMessage.warning('未选择配方');
return;
}
if (!this.selectedPigTypeId) {
ElMessage.warning('请选择目标猪类型');
return;
}
this.loadingAIReview = true;
this.aiReviewResult = null;
this.error = null;
try {
const response = await aiDiagnoseRecipe(this.recipe.id, this.selectedPigTypeId);
if (response.code === 2000 && response.data) {
this.aiReviewResult = response.data;
ElMessage.success('AI点评成功');
} else {
this.error = response.msg || 'AI点评失败';
ElMessage.error(this.error);
}
} catch (err) {
this.error = 'AI点评请求失败: ' + (err.message || '未知错误');
ElMessage.error(this.error);
console.error('AI点评请求失败:', err);
} finally {
this.loadingAIReview = false;
}
},
/**
* 处理对话框关闭
*/
handleClose() {
this.dialogVisible = false;
this.$emit('cancel');
},
},
};
</script>
<style scoped>
.ai-review-content {
background-color: #f5f5f5;
padding: 15px;
border-radius: 4px;
max-height: 60vh;
overflow-y: auto;
}
.markdown-body {
line-height: 1.6;
}
.markdown-body :deep(h1),
.markdown-body :deep(h2),
.markdown-body :deep(h3) {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
.markdown-body :deep(h1) {
font-size: 2em;
border-bottom: 1px solid #eaecef;
padding-bottom: .3em;
}
.markdown-body :deep(h2) {
font-size: 1.5em;
border-bottom: 1px solid #eaecef;
padding-bottom: .3em;
}
.markdown-body :deep(h3) {
font-size: 1.25em;
}
.markdown-body :deep(p) {
margin-bottom: 16px;
}
.markdown-body :deep(ul),
.markdown-body :deep(ol) {
padding-left: 2em;
margin-bottom: 16px;
}
.markdown-body :deep(li) {
margin-bottom: .5em;
}
.markdown-body :deep(table) {
width: 100%;
border-collapse: collapse;
margin-bottom: 16px;
}
.markdown-body :deep(th),
.markdown-body :deep(td) {
border: 1px solid #dfe2e5;
padding: 8px 12px;
}
.markdown-body :deep(th) {
background-color: #f6f8fa;
font-weight: 600;
}
.markdown-body :deep(blockquote) {
color: #6a737d;
border-left: .25em solid #dfe2e5;
padding: 0 1em;
margin-left: 0;
margin-bottom: 16px;
}
.markdown-body :deep(code) {
padding: .2em .4em;
margin: 0;
font-size: 85%;
background-color: rgba(27,31,35,.05);
border-radius: 3px;
}
</style>

View File

@@ -0,0 +1,178 @@
<template>
<el-dialog
title="一键生成配方"
v-model="dialogVisible"
width="500px"
:before-close="handleCancel"
>
<el-form :model="form" ref="generateRecipeForm" label-width="100px">
<el-form-item label="生成对象" prop="selectedPigType"
:rules="[{ required: true, message: '请选择生成对象', trigger: 'change' }]">
<el-select v-model="form.selectedPigType" placeholder="请选择猪品种-猪年龄阶段" style="width: 100%;">
<el-option
v-for="item in pigTypesOptions"
:key="item.value"
:label="item.label"
:value="item.value"
></el-option>
</el-select>
</el-form-item>
<el-form-item label="生成方式" prop="selectedGenerationMethod"
:rules="[{ required: true, message: '请选择生成方式', trigger: 'change' }]">
<el-select v-model="form.selectedGenerationMethod" placeholder="请选择生成方式" style="width: 100%;">
<el-option label="使用系统中所有可用的原料" value="all_raw_materials"></el-option>
<!-- 新增选项 -->
<el-option label="优先使用有库存的原料" value="prefer_in_stock_materials"></el-option>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleGenerate" :loading="loading">生成</el-button>
</span>
</template>
</el-dialog>
</template>
<script>
import {ref, reactive, watch, onMounted, computed} from 'vue';
import {ElMessage} from 'element-plus';
import {FeedApi} from '../../api/feed'; // 假设 FeedApi 包含生成配方接口
export default {
name: 'GenerateRecipeDialog',
props: {
visible: {
type: Boolean,
default: false,
},
},
emits: ['update:visible', 'success', 'cancel'],
setup(props, {emit}) {
const generateRecipeForm = ref(null);
const loading = ref(false);
const pigTypesOptions = ref([]);
const form = reactive({
selectedPigType: '',
selectedGenerationMethod: 'all_raw_materials', // 默认选中
});
// 计算属性,用于控制 dialog 的显示
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val),
});
/**
* 获取猪种类数据并格式化为下拉选项
*/
const fetchPigTypes = async () => {
try {
const response = await FeedApi.getPigTypes({page: 1, page_size: 999}); // 调用 FeedApi 中的 getPigTypes 方法获取猪类型列表
if (response.data && response.data.list) {
pigTypesOptions.value = response.data.list.map(pigType => ({
label: `${pigType.breed_name}-${pigType.age_stage_name}`,
value: `${pigType.id}`, // 下拉框的值直接是 pigType 的 ID
}));
}
} catch (error) {
console.error('获取猪种类失败:', error);
ElMessage.error('获取猪种类失败');
}
};
/**
* 处理生成配方逻辑
*/
const handleGenerate = async () => {
if (!generateRecipeForm.value) return;
generateRecipeForm.value.validate(async (valid) => {
if (valid) {
loading.value = true;
try {
const pigTypeId = parseInt(form.selectedPigType); // 获取选中的 pigType ID
let response = null; // 声明 response 变量
if (form.selectedGenerationMethod === 'all_raw_materials') {
// 调用使用所有原料生成配方的接口
response = await FeedApi.generateRecipeFromAllMaterials(pigTypeId);
} else if (form.selectedGenerationMethod === 'prefer_in_stock_materials') {
// 调用优先使用有库存原料生成配方的接口
response = await FeedApi.generatePrioritizedStockRecipe(pigTypeId);
} else {
ElMessage.error('未知的生成方式');
loading.value = false;
return;
}
if (response.data) {
ElMessage.success('配方生成成功!');
emit('success', response.data.name, response.data.description); // 传递配方名称和简介
dialogVisible.value = false; // 关闭弹窗
} else {
ElMessage.error('配方生成失败:未知错误');
}
} catch (error) {
console.error('一键生成配方失败:', error);
ElMessage.error('一键生成配方失败: ' + (error.response?.data?.message || error.message || '未知错误'));
} finally {
loading.value = false;
}
} else {
ElMessage.warning('请检查表单填写');
return false;
}
});
};
/**
* 处理取消操作
*/
const handleCancel = () => {
dialogVisible.value = false;
emit('cancel');
// 重置表单字段
if (generateRecipeForm.value) {
generateRecipeForm.value.resetFields();
}
form.selectedPigType = ''; // 手动清空,因为 resetFields 不会清空未绑定 prop 的字段
};
// 监听 visible 变化,当弹窗打开时加载数据
watch(() => props.visible, (newVal) => {
if (newVal) {
fetchPigTypes();
// 每次打开时重置表单
if (generateRecipeForm.value) {
generateRecipeForm.value.resetFields();
}
form.selectedPigType = '';
form.selectedGenerationMethod = 'all_raw_materials';
}
});
onMounted(() => {
// 首次加载时也获取一次,以防万一
// fetchPigTypes(); // 移到 watch 中,确保每次打开弹窗都刷新数据
});
return {
generateRecipeForm,
dialogVisible,
form,
pigTypesOptions,
loading,
handleGenerate,
handleCancel,
};
},
};
</script>
<style scoped>
</style>

View File

@@ -1,8 +1,8 @@
<template>
<div class="nutrient-editor">
<el-form label-width="100px">
<el-form label-width="250px" style="max-height: 400px; overflow-y: auto;">
<el-form-item v-for="(nutrient, index) in localNutrients" :key="index" :label="nutrient.nutrient_name">
<el-input-number v-model="nutrient.value" :min="0" controls-position="right" style="width: 150px;"></el-input-number>
<el-input-number v-model="nutrient.value" :min="0" controls-position="right" style="width: 200px;"></el-input-number>
<el-button type="danger" @click="removeNutrient(index)" style="margin-left: 10px;">
<el-icon><Delete /></el-icon>
</el-button>
@@ -10,7 +10,7 @@
</el-form>
<div class="add-nutrient-section">
<el-select v-model="newNutrientId" placeholder="选择要添加的营养素" filterable>
<el-select v-model="newNutrientId" placeholder="选择要添加的营养素" filterable style="flex-grow: 1;">
<el-option v-for="item in availableNutrients" :key="item.id" :label="item.name" :value="item.id"></el-option>
</el-select>
<el-button type="primary" @click="addNutrient" style="margin-left: 10px;">添加</el-button>
@@ -127,6 +127,15 @@ export default {
.nutrient-editor {
padding: 20px;
}
/* 确保 el-form-item 的标签不换行 */
.el-form-item {
/* 移除 display: flex; 因为 label-width 会处理对齐 */
}
.el-form-item__label {
white-space: nowrap; /* 强制不换行 */
/* 移除 flex-shrink: 0; 和 min-width: 150px; 因为 label-width 会处理宽度 */
text-align: right; /* 标签右对齐 */
}
.add-nutrient-section {
margin-top: 20px;
display: flex;

View File

@@ -40,7 +40,7 @@
</template>
</el-table-column>
<!-- 移除 ID -->
<el-table-column prop="name" label="营养名称"></el-table-column>
<el-table-column prop="name" label="营养名称" width="250"></el-table-column>
<el-table-column prop="description" label="描述"></el-table-column>
<el-table-column label="操作" width="180">
<template #default="scope">

View File

@@ -46,7 +46,7 @@
</div>
</template>
</el-table-column>
<el-table-column prop="name" label="年龄阶段名称"></el-table-column>
<el-table-column prop="name" label="年龄阶段名称" width="150"></el-table-column>
<el-table-column prop="description" label="描述"></el-table-column>
<el-table-column label="操作" width="180">
<template #default="scope">
@@ -77,8 +77,8 @@
<PigNutrientRequirementsDisplay
v-if="showNutrientDialog"
:nutrientRequirements="currentNutrientRequirements"
:breedName="currentBreedName"
:ageStageName="currentAgeStageName"
:pigTypeId="currentPigTypeId"
@refresh="handleNutrientRequirementsRefresh"
></PigNutrientRequirementsDisplay>
</el-dialog>
</div>
@@ -108,6 +108,7 @@ export default {
const currentNutrientRequirements = ref([]);
const currentBreedName = ref('');
const currentAgeStageName = ref('');
const currentPigTypeId = ref(null); // 新增:用于传递给营养需求编辑器的 pigType ID
const pagination = ref({
page: 1,
@@ -198,11 +199,23 @@ export default {
};
// 处理查看营养需求详情
const handleViewNutrientRequirements = (pigType) => {
currentNutrientRequirements.value = pigType.pig_nutrient_requirements || [];
currentBreedName.value = pigType.breed_name;
currentAgeStageName.value = pigType.age_stage_name;
showNutrientDialog.value = true;
const handleViewNutrientRequirements = async (pigType) => { // 添加 async
try {
// 强制重新获取该 pigType 的最新详情
const response = await FeedApi.getPigTypeById(pigType.id);
if (response.data) {
currentNutrientRequirements.value = response.data.pig_nutrient_requirements || [];
currentBreedName.value = response.data.breed_name;
currentAgeStageName.value = response.data.age_stage_name;
currentPigTypeId.value = response.data.id; // 设置当前的 pigType ID
showNutrientDialog.value = true;
} else {
ElMessage.error('获取猪类型详情失败');
}
} catch (error) {
console.error('获取猪类型详情失败:', error);
ElMessage.error('获取猪类型详情失败');
}
};
const handleEdit = (row) => {
@@ -231,6 +244,19 @@ export default {
});
};
// 处理营养需求编辑后的刷新事件
const handleNutrientRequirementsRefresh = async () => {
// 重新获取当前展开行的 pig_types 数据
const expandedRow = tableData.value.find(item => expandRowKeys.value.includes(item.id));
if (expandedRow) {
// 重新调用 handleExpandChange 来刷新该行的 pig_types 数据
// 确保传入的 expandedRows 包含当前行,以便触发数据加载逻辑
await handleExpandChange(expandedRow, [expandedRow]);
}
// 关闭弹窗
showNutrientDialog.value = false;
};
onMounted(() => {
fetchPigAgeStages();
});
@@ -246,6 +272,7 @@ export default {
currentNutrientRequirements,
currentBreedName,
currentAgeStageName,
currentPigTypeId,
handleSearch,
handleSizeChange,
handleCurrentChange,
@@ -256,6 +283,7 @@ export default {
handleEdit,
handleDelete,
fetchPigAgeStages, // 将方法暴露出去
handleNutrientRequirementsRefresh, // 暴露给模板
};
},
};

View File

@@ -53,31 +53,49 @@
<el-divider></el-divider>
<!-- 下半段该品种下的年龄阶段简介 -->
<h4 style="margin-bottom: 16px;">该品种下的年龄阶段简介</h4>
<el-table
:data="props.row.pig_types"
border
style="width: 100%; margin-top: 10px;"
>
<el-table-column prop="age_stage_name" label="年龄阶段"></el-table-column>
<el-table-column prop="description" label="描述"></el-table-column>
<el-table-column prop="min_days" label="最小天数"></el-table-column>
<el-table-column prop="max_days" label="最大天数"></el-table-column>
<el-table-column prop="min_weight" label="最小体重(kg)" :formatter="weightFormatter"></el-table-column>
<el-table-column prop="max_weight" label="最大体重(kg)" :formatter="weightFormatter"></el-table-column>
<el-table-column prop="daily_gain_weight" label="日增重(kg)" :formatter="weightFormatter"></el-table-column>
<el-table-column prop="daily_feed_intake" label="日采食量(kg)" :formatter="weightFormatter"></el-table-column>
<!-- 新增营养需求列 -->
<el-table-column label="营养需求" width="120">
<template #default="scope">
<el-button type="text" @click="handleViewNutrientRequirements(scope.row)">查看详情</el-button>
</template>
</el-table-column>
</el-table>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<h4>该品种下的年龄阶段简介</h4>
<el-button type="primary" size="small" @click="handleAddPigType(props.row.id)">添加年龄阶段</el-button>
</div>
<template v-if="props.row.pig_types && props.row.pig_types.length > 0">
<el-table
:data="props.row.pig_types"
border
style="width: 100%; margin-top: 10px;"
>
<el-table-column prop="age_stage_name" label="年龄阶段"></el-table-column>
<el-table-column prop="description" label="描述"></el-table-column>
<el-table-column prop="min_days" label="最小天数"></el-table-column>
<el-table-column prop="max_days" label="最大天数"></el-table-column>
<el-table-column prop="min_weight" label="最小体重(kg)" :formatter="weightFormatter"></el-table-column>
<el-table-column prop="max_weight" label="最大体重(kg)" :formatter="weightFormatter"></el-table-column>
<el-table-column prop="daily_gain_weight" label="日增重(kg)" :formatter="weightFormatter"></el-table-column>
<el-table-column prop="daily_feed_intake" label="日采食量(kg)" :formatter="weightFormatter"></el-table-column>
<!-- 新增营养需求列 -->
<el-table-column label="营养需求" width="120">
<template #default="scope">
<el-button type="text" @click="handleViewNutrientRequirements(scope.row)">查看详情</el-button>
</template>
</el-table-column>
<!-- 新增操作列 -->
<el-table-column label="操作" width="150">
<template #default="scope">
<el-button size="small" @click="handleEditPigType(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDeletePigType(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</template>
<template v-else>
<el-empty description="暂无年龄阶段数据">
<el-button type="primary" @click="handleAddPigType(props.row.id)">点击添加首个年龄阶段</el-button>
</el-empty>
</template>
</div>
</template>
</el-table-column>
<el-table-column prop="name" label="品种名称"></el-table-column>
<el-table-column prop="name" label="品种名称" width="150"></el-table-column>
<el-table-column prop="description" label="描述"></el-table-column>
<el-table-column label="操作" width="180">
<template #default="scope">
@@ -102,16 +120,34 @@
<el-dialog
v-model="showNutrientDialog"
:title="`品种 ${currentBreedName} - 年龄阶段 ${currentAgeStageName} 营养需求`"
width="600px"
width="700px"
:close-on-click-modal="false"
>
<PigNutrientRequirementsDisplay
v-if="showNutrientDialog"
:nutrientRequirements="currentNutrientRequirements"
:breedName="currentBreedName"
:ageStageName="currentAgeStageName"
:pigTypeId="currentPigTypeId"
@refresh="handleNutrientRequirementsRefresh"
></PigNutrientRequirementsDisplay>
</el-dialog>
<!-- 新增年龄阶段编辑弹窗 -->
<el-dialog
v-model="showPigTypeDialog"
:title="isEditingPigType ? '编辑年龄阶段' : '添加年龄阶段'"
width="600px"
:close-on-click-modal="false"
>
<PigTypeEditor
v-if="showPigTypeDialog"
:initialData="currentPigType"
:breedId="currentBreedIdForPigType"
:isEditing="isEditingPigType"
:existingAgeStageIds="getExistingAgeStageIds(currentBreedIdForPigType)"
@save="handlePigTypeSave"
@cancel="handlePigTypeCancel"
></PigTypeEditor>
</el-dialog>
</div>
</template>
@@ -120,12 +156,14 @@ import {ref, onMounted} from 'vue';
import {FeedApi} from '../../api/feed';
import {ElMessageBox, ElMessage} from 'element-plus';
import PigNutrientRequirementsDisplay from './PigNutrientRequirementsDisplay.vue'; // 导入新的组件
import PigTypeEditor from './PigTypeEditor.vue'; // 导入年龄阶段编辑器组件
export default {
name: 'PigBreedTable',
emits: ['edit'], // 声明触发的事件
components: {
PigNutrientRequirementsDisplay, // 注册组件
PigTypeEditor, // 注册年龄阶段编辑器组件
},
setup(props, { emit }) {
const mainTable = ref(null); // el-table 的引用
@@ -139,6 +177,12 @@ export default {
const currentNutrientRequirements = ref([]);
const currentBreedName = ref('');
const currentAgeStageName = ref('');
const currentPigTypeId = ref(null); // 新增:用于传递给营养需求编辑器的 pigType ID
// 年龄阶段编辑弹窗相关
const showPigTypeDialog = ref(false);
const currentPigType = ref({}); // 当前编辑的年龄阶段数据
const currentBreedIdForPigType = ref(null); // 当前操作的品种ID
const isEditingPigType = ref(false); // 是否是编辑模式
const pagination = ref({
page: 1,
@@ -231,11 +275,23 @@ export default {
};
// 处理查看营养需求详情
const handleViewNutrientRequirements = (pigType) => {
currentNutrientRequirements.value = pigType.pig_nutrient_requirements || [];
currentBreedName.value = pigType.breed_name;
currentAgeStageName.value = pigType.age_stage_name;
showNutrientDialog.value = true;
const handleViewNutrientRequirements = async (pigType) => { // 添加 async
try {
// 强制重新获取该 pigType 的最新详情
const response = await FeedApi.getPigTypeById(pigType.id);
if (response.data) {
currentNutrientRequirements.value = response.data.pig_nutrient_requirements || [];
currentBreedName.value = response.data.breed_name;
currentAgeStageName.value = response.data.age_stage_name;
currentPigTypeId.value = response.data.id; // 设置当前的 pigType ID
showNutrientDialog.value = true;
} else {
ElMessage.error('获取猪类型详情失败');
}
} catch (error) {
console.error('获取猪类型详情失败:', error);
ElMessage.error('获取猪类型详情失败');
}
};
const handleEdit = (row) => {
@@ -264,6 +320,116 @@ export default {
});
};
// 处理营养需求编辑后的刷新事件
const handleNutrientRequirementsRefresh = async () => {
// 重新获取当前展开行的 pig_types 数据
const expandedRow = tableData.value.find(item => expandRowKeys.value.includes(item.id));
if (expandedRow) {
// 重新调用 handleExpandChange 来刷新该行的 pig_types 数据
// 确保传入的 expandedRows 包含当前行,以便触发数据加载逻辑
await handleExpandChange(expandedRow, [expandedRow]);
}
// 关闭弹窗
showNutrientDialog.value = false;
};
// 获取当前品种已有的年龄阶段ID列表
const getExistingAgeStageIds = (breedId) => {
const breed = tableData.value.find(item => item.id === breedId);
return breed && breed.pig_types ? breed.pig_types.map(pt => pt.age_stage_id) : [];
};
// 处理添加年龄阶段
const handleAddPigType = (breedId) => {
isEditingPigType.value = false;
currentPigType.value = { // 初始化新年龄阶段的数据
age_stage_id: null, // age_stage_id 默认为 null由下拉框选择
description: '',
min_days: 0,
max_days: 0,
min_weight: 0,
max_weight: 0,
daily_gain_weight: 0,
daily_feed_intake: 0,
};
currentBreedIdForPigType.value = breedId;
showPigTypeDialog.value = true;
};
// 处理编辑年龄阶段
const handleEditPigType = (pigType) => {
isEditingPigType.value = true;
currentPigType.value = { ...pigType }; // 复制一份数据进行编辑
currentBreedIdForPigType.value = pigType.breed_id;
showPigTypeDialog.value = true;
};
// 处理删除年龄阶段
const handleDeletePigType = (pigType) => {
ElMessageBox.confirm(
`确定要删除年龄阶段 "${pigType.age_stage_name}" 吗?`,
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
).then(async () => {
try {
await FeedApi.deletePigType(pigType.id);
ElMessage.success('年龄阶段删除成功');
// 刷新当前品种的 pig_types 数据
const breedIdToRefresh = pigType.breed_id;
if (breedIdToRefresh) {
try {
const response = await FeedApi.getPigTypes({ breed_id: breedIdToRefresh, page: 1, page_size: 999 });
if (response.data && response.data.list) {
const sortedPigTypes = response.data.list.sort((a, b) => a.age_stage_id - b.age_stage_id);
const index = tableData.value.findIndex(item => item.id === breedIdToRefresh);
if (index !== -1) {
tableData.value[index].pig_types = sortedPigTypes;
}
}
} catch (error) {
console.error('刷新该品种下的猪类型失败:', error);
ElMessage.error('刷新猪类型失败');
}
}
} catch (error) {
ElMessage.error('删除失败: ' + (error.message || '未知错误'));
}
}).catch(() => {
// 用户取消操作
});
};
// 年龄阶段编辑器保存后的回调
const handlePigTypeSave = async () => {
showPigTypeDialog.value = false;
// 刷新当前品种的 pig_types 数据
const breedIdToRefresh = currentBreedIdForPigType.value;
if (breedIdToRefresh) {
try {
const response = await FeedApi.getPigTypes({ breed_id: breedIdToRefresh, page: 1, page_size: 999 });
if (response.data && response.data.list) {
const sortedPigTypes = response.data.list.sort((a, b) => a.age_stage_id - b.age_stage_id);
const index = tableData.value.findIndex(item => item.id === breedIdToRefresh);
if (index !== -1) {
tableData.value[index].pig_types = sortedPigTypes;
}
}
} catch (error) {
console.error('刷新该品种下的猪类型失败:', error);
ElMessage.error('刷新猪类型失败');
}
}
};
// 年龄阶段编辑器取消后的回调
const handlePigTypeCancel = () => {
showPigTypeDialog.value = false;
};
onMounted(() => {
fetchPigBreeds();
});
@@ -279,6 +445,11 @@ export default {
currentNutrientRequirements,
currentBreedName,
currentAgeStageName,
currentPigTypeId,
showPigTypeDialog,
currentPigType,
currentBreedIdForPigType,
isEditingPigType,
handleSearch,
handleSizeChange,
handleCurrentChange,
@@ -289,6 +460,13 @@ export default {
handleEdit,
handleDelete,
fetchPigBreeds, // 将方法暴露出去
handleAddPigType,
handleEditPigType,
handleDeletePigType,
getExistingAgeStageIds, // 暴露给模板
handleNutrientRequirementsRefresh, // 暴露给模板
handlePigTypeSave,
handlePigTypeCancel,
};
},
};

View File

@@ -1,35 +1,72 @@
<template>
<div class="pig-nutrient-requirements-display">
<el-table :data="nutrientRequirements" border style="width: 100%">
<el-table-column prop="nutrient_name" label="营养素名称"></el-table-column>
<el-table-column prop="min_requirement" label="最小需求量"></el-table-column>
<el-table-column prop="max_requirement" label="最大需求量"></el-table-column>
</el-table>
<div v-if="nutrientRequirements.length === 0" class="no-data-message">
暂无该品种该年龄阶段的营养需求数据
<div v-if="!isEditing">
<div class="edit-button-container">
<el-button type="primary" @click="handleEdit">编辑营养需求</el-button>
</div>
<el-table :data="nutrientRequirements" border style="width: 100%; max-height: 300px; overflow-y: auto;">
<el-table-column prop="nutrient_name" label="营养素名称"></el-table-column>
<el-table-column prop="min_requirement" label="最小需求量"></el-table-column>
<el-table-column prop="max_requirement" label="最大需求量"></el-table-column>
</el-table>
<div v-if="nutrientRequirements.length === 0" class="no-data-message">
暂无该品种该年龄阶段的营养需求数据
</div>
</div>
<PigNutrientRequirementsEditor
v-else
:initialNutrientRequirements="nutrientRequirements"
:pigTypeId="pigTypeId"
@save="handleSave"
@cancel="handleCancel"
></PigNutrientRequirementsEditor>
</div>
</template>
<script>
import { defineComponent } from 'vue';
import { defineComponent, ref } from 'vue';
import PigNutrientRequirementsEditor from './PigNutrientRequirementsEditor.vue';
export default defineComponent({
name: 'PigNutrientRequirementsDisplay',
components: {
PigNutrientRequirementsEditor,
},
props: {
nutrientRequirements: {
type: Array,
default: () => [],
},
breedName: {
type: String,
default: '',
},
ageStageName: {
type: String,
default: '',
pigTypeId: { // 猪类型ID用于传递给编辑器组件
type: Number,
required: true,
},
},
emits: ['refresh'], // 声明触发的事件,用于通知父组件刷新数据
setup(props, { emit }) {
const isEditing = ref(false); // 控制显示展示模式还是编辑模式
const handleEdit = () => {
isEditing.value = true;
};
const handleSave = () => {
isEditing.value = false;
emit('refresh'); // 通知父组件刷新数据
};
const handleCancel = () => {
isEditing.value = false;
};
return {
isEditing,
handleEdit,
handleSave,
handleCancel,
};
},
});
</script>
@@ -43,4 +80,8 @@ export default defineComponent({
color: #909399;
margin-top: 20px;
}
.edit-button-container {
margin-bottom: 20px; /* 调整按钮与表格之间的间距 */
text-align: right;
}
</style>

View File

@@ -0,0 +1,190 @@
<template>
<div class="pig-nutrient-requirements-editor">
<el-form label-width="250px" style="max-height: 300px; overflow-y: auto;">
<el-form-item v-for="(req, index) in localNutrientRequirements" :key="req.nutrient_id" :label="req.nutrient_name">
<div class="nutrient-input-group">
<el-input-number
v-model="req.min_requirement"
:min="0"
:step="0.01"
:precision="2"
controls-position="right"
placeholder="最小值"
style="width: 120px;"
></el-input-number>
<span style="margin: 0 10px;">-</span>
<el-input-number
v-model="req.max_requirement"
:min="0"
:step="0.01"
:precision="2"
controls-position="right"
placeholder="最大值"
style="width: 120px;"
></el-input-number>
<el-button type="danger" @click="removeNutrientRequirement(index)" style="margin-left: 10px;">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</el-form-item>
</el-form>
<div class="add-nutrient-section">
<el-select v-model="newNutrientId" placeholder="选择要添加的营养素" filterable style="flex-grow: 1;">
<el-option v-for="item in availableNutrients" :key="item.id" :label="item.name" :value="item.id"></el-option>
</el-select>
<el-button type="primary" @click="addNutrientRequirement" style="margin-left: 10px;">添加</el-button>
</div>
<div class="dialog-footer">
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</div>
</div>
</template>
<script>
import { ref, watch, onMounted, computed } from 'vue';
import { FeedApi } from '../../api/feed';
import { ElMessage } from 'element-plus';
import { Delete } from '@element-plus/icons-vue';
export default {
name: 'PigNutrientRequirementsEditor',
components: {
Delete,
},
props: {
initialNutrientRequirements: {
type: Array,
default: () => [],
},
pigTypeId: { // 猪类型ID用于更新API
type: Number,
required: true,
}
},
emits: ['save', 'cancel'],
setup(props, { emit }) {
const localNutrientRequirements = ref([]);
const allNutrients = ref([]); // 所有营养素列表
const newNutrientId = ref(null);
// 计算属性,用于过滤掉已经存在的营养素
const availableNutrients = computed(() => {
const existingNutrientIds = new Set(localNutrientRequirements.value.map(req => req.nutrient_id));
return allNutrients.value.filter(nutrient => !existingNutrientIds.has(nutrient.id));
});
// 监听 initialNutrientRequirements 的变化,深拷贝以避免直接修改 props
watch(() => props.initialNutrientRequirements, (newData) => {
localNutrientRequirements.value = JSON.parse(JSON.stringify(newData));
}, { immediate: true, deep: true });
// 获取所有可用的营养素列表
const fetchAllNutrients = async () => {
try {
const response = await FeedApi.getNutrients({ page_size: 999 }); // 获取所有营养素
if (response.data && response.data.list) {
allNutrients.value = response.data.list;
}
} catch (error) {
ElMessage.error('获取营养素列表失败');
}
};
const removeNutrientRequirement = (index) => {
localNutrientRequirements.value.splice(index, 1);
};
const addNutrientRequirement = () => {
if (!newNutrientId.value) {
ElMessage.warning('请先选择一个营养素');
return;
}
const nutrientToAdd = allNutrients.value.find(n => n.id === newNutrientId.value);
if (nutrientToAdd) {
localNutrientRequirements.value.push({
nutrient_id: nutrientToAdd.id,
nutrient_name: nutrientToAdd.name,
min_requirement: 0, // 默认最小值
max_requirement: 0, // 默认最大值
});
newNutrientId.value = null; // 重置选择
}
};
const handleSave = async () => {
// 验证 min_requirement <= max_requirement
for (const req of localNutrientRequirements.value) {
if (req.min_requirement > req.max_requirement) {
ElMessage.error(`营养素 ${req.nutrient_name} 的最小值不能大于最大值`);
return;
}
}
// 构造符合 API 要求的数据格式
const requirementsToSave = localNutrientRequirements.value.map(req => ({
nutrient_id: req.nutrient_id,
min_requirement: req.min_requirement,
max_requirement: req.max_requirement,
}));
try {
// 调用API更新营养需求
await FeedApi.updatePigTypeNutrientRequirements(props.pigTypeId, { nutrient_requirements: requirementsToSave });
ElMessage.success('营养需求更新成功');
emit('save'); // 通知父组件保存成功
} catch (error) {
ElMessage.error('保存失败: ' + (error.message || '未知错误'));
}
};
const handleCancel = () => {
emit('cancel');
};
onMounted(() => {
fetchAllNutrients();
});
return {
localNutrientRequirements,
availableNutrients,
newNutrientId,
removeNutrientRequirement,
addNutrientRequirement,
handleSave,
handleCancel,
};
},
};
</script>
<style scoped>
.pig-nutrient-requirements-editor {
padding: 20px;
}
/* 确保 el-form-item 的标签不换行 */
.el-form-item {
/* 移除 display: flex; 因为 label-width 会处理对齐 */
}
.el-form-item__label {
white-space: nowrap; /* 强制不换行 */
/* 移除 flex-shrink: 0; 和 min-width: 150px; 因为 label-width 会处理宽度 */
text-align: right; /* 标签右对齐 */
}
.nutrient-input-group {
display: flex;
align-items: center;
}
.add-nutrient-section {
margin-top: 20px;
display: flex;
align-items: center;
}
.dialog-footer {
margin-top: 30px;
text-align: right;
}
</style>

View File

@@ -0,0 +1,175 @@
<template>
<div class="pig-type-editor">
<el-form :model="formData" ref="formRef" label-width="120px">
<el-form-item label="年龄阶段" prop="age_stage_id" :rules="[{ required: true, message: '请选择年龄阶段', trigger: 'change' }]">
<el-select v-model="formData.age_stage_id" placeholder="请选择年龄阶段" filterable style="width: 100%;">
<el-option
v-for="item in ageStages"
:key="item.id"
:label="item.name"
:value="item.id"
:disabled="!isEditing && existingAgeStageIds.includes(item.id)"
></el-option>
</el-select>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input type="textarea" v-model="formData.description"></el-input>
</el-form-item>
<el-form-item label="最小天数" prop="min_days" :rules="[{ required: true, message: '请输入最小天数', trigger: 'blur' }]">
<el-input-number v-model="formData.min_days" :min="0" controls-position="right"></el-input-number>
</el-form-item>
<el-form-item label="最大天数" prop="max_days" :rules="[{ required: true, message: '请输入最大天数', trigger: 'blur' }]">
<el-input-number v-model="formData.max_days" :min="0" controls-position="right"></el-input-number>
</el-form-item>
<el-form-item label="最小体重(kg)" prop="min_weight">
<el-input-number v-model="formData.min_weight" :min="0" :step="0.01" :precision="2" controls-position="right"></el-input-number>
</el-form-item>
<el-form-item label="最大体重(kg)" prop="max_weight">
<el-input-number v-model="formData.max_weight" :min="0" :step="0.01" :precision="2" controls-position="right"></el-input-number>
</el-form-item>
<el-form-item label="日增重(kg)" prop="daily_gain_weight">
<el-input-number v-model="formData.daily_gain_weight" :min="0" :step="0.01" :precision="2" controls-position="right"></el-input-number>
</el-form-item>
<el-form-item label="日采食量(kg)" prop="daily_feed_intake">
<el-input-number v-model="formData.daily_feed_intake" :min="0" :step="0.01" :precision="2" controls-position="right"></el-input-number>
</el-form-item>
</el-form>
<div class="dialog-footer">
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</div>
</div>
</template>
<script>
import { ref, watch, onMounted } from 'vue';
import { ElMessage } from 'element-plus';
import { FeedApi } from '../../api/feed';
export default {
name: 'PigTypeEditor',
props: {
initialData: {
type: Object,
default: () => ({
age_stage_id: null, // 确保默认值是 null
}),
},
breedId: {
type: Number,
required: true,
},
// 新增一个 prop 来接收当前品种已有的 age_stage_id 列表
// 用于在添加模式下禁用已选择的年龄阶段
existingAgeStageIds: {
type: Array,
default: () => [],
},
// 新增一个 prop 来指示是否是编辑模式
isEditing: {
type: Boolean,
default: false,
},
},
emits: ['save', 'cancel'],
setup(props, { emit }) {
const formRef = ref(null);
const formData = ref({});
const ageStages = ref([]); // 用于存储所有年龄阶段列表
watch(() => props.initialData, (newData) => {
// 复制一份数据避免直接修改props
formData.value = { ...newData };
// 如果是添加模式age_stage_id 应该为 null
if (!props.isEditing) {
formData.value.age_stage_id = null;
}
// 将克转换为公斤显示
if (formData.value.min_weight !== undefined && formData.value.min_weight !== null) formData.value.min_weight /= 1000;
if (formData.value.max_weight !== undefined && formData.value.max_weight !== null) formData.value.max_weight /= 1000;
if (formData.value.daily_gain_weight !== undefined && formData.value.daily_gain_weight !== null) formData.value.daily_gain_weight /= 1000;
if (formData.value.daily_feed_intake !== undefined && formData.value.daily_feed_intake !== null) formData.value.daily_feed_intake /= 1000;
// 确保所有重量字段都有默认值,避免 NaN
formData.value.min_weight = formData.value.min_weight || 0;
formData.value.max_weight = formData.value.max_weight || 0;
formData.value.daily_gain_weight = formData.value.daily_gain_weight || 0;
formData.value.daily_feed_intake = formData.value.daily_feed_intake || 0;
}, { immediate: true, deep: true });
const handleSave = () => {
formRef.value.validate(async (valid) => {
if (valid) {
try {
const dataToSave = {
breed_id: props.breedId,
age_stage_id: formData.value.age_stage_id,
description: formData.value.description,
min_days: formData.value.min_days,
max_days: formData.value.max_days,
min_weight: formData.value.min_weight * 1000, // Convert kg to g
max_weight: formData.value.max_weight * 1000, // Convert kg to g
daily_gain_weight: formData.value.daily_gain_weight * 1000, // Convert kg to g
daily_feed_intake: formData.value.daily_feed_intake * 1000, // Convert kg to g
};
if (formData.value.id) {
// 编辑现有年龄阶段
await FeedApi.updatePigType(formData.value.id, dataToSave);
ElMessage.success('年龄阶段更新成功');
} else {
// 添加新年龄阶段
await FeedApi.createPigType(dataToSave);
ElMessage.success('年龄阶段添加成功');
}
emit('save');
} catch (error) {
ElMessage.error('操作失败: ' + (error.message || '未知错误'));
}
}
});
};
const handleCancel = () => {
emit('cancel');
};
// 获取所有年龄阶段列表
const fetchAgeStages = async () => {
try {
const response = await FeedApi.getPigAgeStages({ page: 1, page_size: 999 }); // 明确传递 page: 1
if (response.data && response.data.list) {
ageStages.value = response.data.list;
}
} catch (error) {
ElMessage.error('获取年龄阶段列表失败');
}
};
onMounted(() => {
fetchAgeStages();
});
return {
formRef,
formData,
ageStages,
handleSave,
handleCancel,
};
},
};
</script>
<style scoped>
.pig-type-editor {
padding: 20px;
}
.dialog-footer {
margin-top: 20px;
text-align: right;
}
</style>

View File

@@ -1,8 +1,14 @@
<template>
<el-form :model="formData" :rules="rules" ref="formRef" label-width="100px">
<el-form :model="formData" :rules="rules" ref="formRef" label-width="150px">
<el-form-item label="原料名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入原料名称"></el-input>
</el-form-item>
<el-form-item label="参考价格(kg/元)" prop="reference_price">
<el-input-number v-model="formData.reference_price" :min="0" :precision="2" :step="0.01" controls-position="right" style="width: 100%;"></el-input-number>
</el-form-item>
<el-form-item label="最大添加比例(%)" prop="max_addition_ratio">
<el-input-number v-model="formData.max_addition_ratio" :min="0" :max="100" :precision="2" :step="1" controls-position="right" style="width: 100%;"></el-input-number>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input
v-model="formData.description"
@@ -34,6 +40,8 @@ export default {
default: () => ({
name: '',
description: '',
reference_price: 0,
max_addition_ratio: 0,
}),
},
},
@@ -43,6 +51,8 @@ export default {
const formData = reactive({
name: '',
description: '',
reference_price: 0,
max_addition_ratio: 0,
});
// 监听 initialData 变化,用于编辑模式下初始化表单
@@ -52,6 +62,8 @@ export default {
if (newVal) {
formData.name = newVal.name || '';
formData.description = newVal.description || '';
formData.reference_price = newVal.reference_price || 0;
formData.max_addition_ratio = newVal.max_addition_ratio || 0;
}
},
{ immediate: true, deep: true }
@@ -62,6 +74,15 @@ export default {
{ required: true, message: '请输入原料名称', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' },
],
reference_price: [
{ required: true, message: '请输入参考价格', trigger: 'change' },
{ type: 'number', message: '参考价格必须是数字', trigger: 'change' },
{ min: 0, type: 'number', message: '参考价格不能小于0', trigger: 'change' },
],
max_addition_ratio: [
{ required: true, message: '请输入最大添加比例', trigger: 'change' },
{ type: 'number', min: 0, max: 100, message: '比例必须在 0 到 100 之间', trigger: 'change' },
],
};
const submitForm = () => {
@@ -85,6 +106,8 @@ export default {
// 手动重置 formData因为 resetFields 不会重置未绑定 prop 的字段
formData.name = '';
formData.description = '';
formData.reference_price = 0;
formData.max_addition_ratio = 0;
};
return {

View File

@@ -38,7 +38,14 @@
</div>
</template>
</el-table-column>
<el-table-column prop="name" label="原料名称"></el-table-column>
<el-table-column prop="name" label="原料名称" width="150"></el-table-column>
<el-table-column prop="reference_price" label="参考价格(kg/元)" width="180"></el-table-column>
<el-table-column
prop="max_addition_ratio"
label="最大添加比例(%)"
width="180"
:formatter="formatMaxAdditionRatio"
></el-table-column>
<el-table-column prop="description" label="描述"></el-table-column>
<el-table-column label="操作">
<template #default="scope">
@@ -147,7 +154,7 @@ export default {
// 处理行点击事件
const handleRowClick = (row, column) => {
// 如果点击的是操作列,则不执行任何操作
if (column.label === '操作') {
if (column && column.label === '操作') {
return;
}
// 否则,切换行的展开状态
@@ -184,6 +191,13 @@ export default {
emit('edit-nutrients', row); // 触发 edit-nutrients 事件
};
const formatMaxAdditionRatio = (row, column, cellValue) => {
if (typeof cellValue === 'number') {
return cellValue.toFixed(2);
}
return cellValue;
};
onMounted(() => {
fetchRawMaterials();
});
@@ -205,6 +219,7 @@ export default {
handleDelete,
handleEditNutrients,
fetchRawMaterials, // 将方法暴露出去
formatMaxAdditionRatio,
};
},
};

View File

@@ -0,0 +1,359 @@
<template>
<el-dialog
:model-value="visible"
title="配方对比"
@close="handleClose"
width="80%"
top="5vh"
>
<el-form :inline="true" class="compare-form">
<el-form-item label="对比类型">
<el-select v-model="compareType" placeholder="请选择对比类型" style="width: 200px;">
<el-option label="配方" value="recipe"></el-option>
<el-option label="猪的营养需求" value="pigType"></el-option>
</el-select>
</el-form-item>
<el-form-item v-if="compareType === 'recipe'" label="选择配方">
<el-select v-model="selectedCompareRecipeId" filterable placeholder="请选择对比配方" style="width: 300px;">
<el-option
v-for="item in recipeList"
:key="item.id"
:label="item.name"
:value="item.id"
:disabled="item.id === currentRecipe.id"
></el-option>
</el-select>
</el-form-item>
<el-form-item v-if="compareType === 'pigType'" label="选择猪类型">
<el-select v-model="selectedPigTypeId" filterable placeholder="请选择猪类型" style="width: 300px;">
<el-option
v-for="item in pigTypeList"
:key="item.id"
:label="item.breed_name + ' - ' + item.age_stage_name"
:value="item.id"
></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="startCompare" :disabled="!canCompare">开始对比</el-button>
</el-form-item>
</el-form>
<el-divider />
<div v-if="comparing" class="loading-spinner">
<el-skeleton :rows="5" animated />
</div>
<div v-else-if="compareError" class="error-message">
<el-alert
title="对比数据加载失败"
:description="compareError"
type="error"
show-icon
@close="compareError = null"
/>
</div>
<div v-else-if="compareResult.length > 0" class="table-wrapper">
<el-table :data="compareResult" style="width: 100%" border>
<el-table-column label="营养素" fixed>
<template #default="scope">
<el-tooltip :content="nutrientsDescriptionMap[scope.row.nutrientName]" placement="top">
<span>{{ scope.row.nutrientName }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="currentRecipe.name">
<template #default="scope">
<span :style="{ color: scope.row.currentRecipeValue > scope.row.compareRecipeValue ? 'green' : (scope.row.currentRecipeValue < scope.row.compareRecipeValue ? 'red' : 'inherit') }">
{{ scope.row.currentRecipeValue !== undefined ? scope.row.currentRecipeValue.toFixed(2) : '-' }}
</span>
</template>
</el-table-column>
<template v-if="compareType === 'recipe'">
<el-table-column :label="compareRecipeName">
<template #default="scope">
<span :style="{ color: scope.row.compareRecipeValue > scope.row.currentRecipeValue ? 'green' : (scope.row.compareRecipeValue < scope.row.currentRecipeValue ? 'red' : 'inherit') }">
{{ scope.row.compareRecipeValue !== undefined ? scope.row.compareRecipeValue.toFixed(2) : '-' }}
</span>
</template>
</el-table-column>
</template>
<template v-else-if="compareType === 'pigType'">
<el-table-column :label="pigTypeName + ' (下限)'">
<template #default="scope">
{{ scope.row.minRequirement !== undefined ? scope.row.minRequirement.toFixed(2) : '-' }}
</template>
</el-table-column>
<el-table-column :label="pigTypeName + ' (上限)'">
<template #default="scope">
{{ scope.row.maxRequirement !== undefined ? scope.row.maxRequirement.toFixed(2) : '-' }}
</template>
</el-table-column>
<el-table-column label="是否达标" align="center">
<template #default="scope">
<el-icon v-if="scope.row.isMet" color="green"><Check /></el-icon>
<el-icon v-else color="red"><Close /></el-icon>
</template>
</el-table-column>
</template>
</el-table>
</div>
<div v-else class="no-data-message">
<el-empty description="请选择对比项并点击开始对比" />
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose">关闭</el-button>
</span>
</template>
</el-dialog>
</template>
<script>
import { ref, watch, computed } from 'vue';
import { getNutrients, getRecipes, getRecipeById, getPigTypes, getPigTypeById, getRawMaterialById } from '../../api/feed';
import { ElMessage } from 'element-plus';
import { Check, Close } from '@element-plus/icons-vue';
export default {
name: 'RecipeCompareDialog',
components: {
Check,
Close,
},
props: {
visible: Boolean,
currentRecipe: {
type: Object,
required: true,
},
},
emits: ['update:visible'],
setup(props, { emit }) {
const compareType = ref('recipe'); // 'recipe' or 'pigType'
const recipeList = ref([]);
const selectedCompareRecipeId = ref(null);
const pigTypeList = ref([]);
const selectedPigTypeId = ref(null);
const comparing = ref(false);
const compareError = ref(null);
const compareResult = ref([]);
const compareRecipeName = ref('');
const pigTypeName = ref('');
const nutrientsDescriptionMap = ref({}); // 新增:存储营养素描述的映射
const handleClose = () => {
emit('update:visible', false);
// 重置状态
compareType.value = 'recipe';
selectedCompareRecipeId.value = null;
selectedPigTypeId.value = null;
compareResult.value = [];
compareError.value = null;
compareRecipeName.value = '';
pigTypeName.value = '';
};
const canCompare = computed(() => {
if (compareType.value === 'recipe') {
return selectedCompareRecipeId.value !== null;
} else if (compareType.value === 'pigType') {
return selectedPigTypeId.value !== null;
}
return false;
});
// 获取所有配方列表
const fetchRecipeList = async () => {
try {
const response = await getRecipes({ page_size: 999 });
recipeList.value = response.data.list || [];
} catch (err) {
ElMessage.error('获取配方列表失败: ' + (err.message || '未知错误'));
}
};
// 获取所有猪类型列表
const fetchPigTypeList = async () => {
try {
const response = await getPigTypes({ page: 1, page_size: 999 });
pigTypeList.value = response.data.list || [];
} catch (err) {
ElMessage.error('获取猪类型列表失败: ' + (err.message || '未知错误'));
}
};
// 新增:获取所有营养素列表及其描述
const fetchNutrientsDescriptions = async () => {
try {
const response = await getNutrients({ page_size: 999 });
nutrientsDescriptionMap.value = response.data.list.reduce((map, nutrient) => {
map[nutrient.name] = nutrient.description;
return map;
}, {});
} catch (err) {
ElMessage.error('获取营养素描述失败: ' + (err.message || '未知错误'));
}
};
// 计算配方的营养成分汇总
const calculateRecipeNutrientSummary = async (recipe) => {
const summary = new Map();
if (!recipe || !recipe.recipe_ingredients || recipe.recipe_ingredients.length === 0) {
return summary;
}
const rawMaterialPromises = recipe.recipe_ingredients.map(ing => getRawMaterialById(ing.raw_material_id));
const rawMaterialResponses = await Promise.all(rawMaterialPromises);
const ingredientDetails = rawMaterialResponses.map((res, index) => ({
...res.data,
percentage: recipe.recipe_ingredients[index].percentage,
}));
ingredientDetails.forEach(ing => {
if (ing.raw_material_nutrients) {
ing.raw_material_nutrients.forEach(nutrient => {
const contribution = nutrient.value * (ing.percentage / 100);
if (summary.has(nutrient.nutrient_name)) {
summary.set(nutrient.nutrient_name, summary.get(nutrient.nutrient_name) + contribution);
} else {
summary.set(nutrient.nutrient_name, contribution);
}
});
}
});
return summary;
};
const startCompare = async () => {
comparing.value = true;
compareError.value = null;
compareResult.value = [];
try {
const currentRecipeNutrients = await calculateRecipeNutrientSummary(props.currentRecipe);
if (compareType.value === 'recipe') {
const compareRecipe = recipeList.value.find(r => r.id === selectedCompareRecipeId.value);
if (!compareRecipe) {
throw new Error('未找到对比配方');
}
compareRecipeName.value = compareRecipe.name;
const otherRecipeDetails = await getRecipeById(selectedCompareRecipeId.value);
const otherRecipeNutrients = await calculateRecipeNutrientSummary(otherRecipeDetails.data);
const allNutrientNames = new Set([...currentRecipeNutrients.keys(), ...otherRecipeNutrients.keys()]);
allNutrientNames.forEach(name => {
const currentRecipeValueRaw = currentRecipeNutrients.has(name) ? currentRecipeNutrients.get(name) : undefined;
const compareRecipeValueRaw = otherRecipeNutrients.has(name) ? otherRecipeNutrients.get(name) : undefined;
const currentRecipeValue = currentRecipeValueRaw !== undefined ? parseFloat(currentRecipeValueRaw.toFixed(2)) : undefined;
const compareRecipeValue = compareRecipeValueRaw !== undefined ? parseFloat(compareRecipeValueRaw.toFixed(2)) : undefined;
compareResult.value.push({
nutrientName: name,
currentRecipeValue,
compareRecipeValue,
});
});
} else if (compareType.value === 'pigType') {
const pigType = pigTypeList.value.find(p => p.id === selectedPigTypeId.value);
if (!pigType) {
throw new Error('未找到猪类型');
}
pigTypeName.value = `${pigType.breed_name} - ${pigType.age_stage_name}`;
const pigTypeDetails = await getPigTypeById(selectedPigTypeId.value);
const pigNutrientRequirements = new Map();
pigTypeDetails.data.pig_nutrient_requirements.forEach(req => {
pigNutrientRequirements.set(req.nutrient_name, {
min: req.min_requirement,
max: req.max_requirement,
});
});
const allNutrientNames = new Set([...currentRecipeNutrients.keys(), ...pigNutrientRequirements.keys()]);
allNutrientNames.forEach(name => {
const currentRecipeValueRaw = currentRecipeNutrients.has(name) ? currentRecipeNutrients.get(name) : undefined;
const requirementRaw = pigNutrientRequirements.has(name) ? pigNutrientRequirements.get(name) : { min: undefined, max: undefined };
const currentRecipeValue = currentRecipeValueRaw !== undefined ? parseFloat(currentRecipeValueRaw.toFixed(2)) : undefined;
const minRequirement = requirementRaw.min !== undefined ? parseFloat(requirementRaw.min.toFixed(2)) : undefined;
const maxRequirement = requirementRaw.max !== undefined ? parseFloat(requirementRaw.max.toFixed(2)) : undefined;
const isMet = (minRequirement === undefined && maxRequirement === undefined) || // 如果猪没有这个营养素的需求,则认为达标
(currentRecipeValue !== undefined &&
(minRequirement === undefined || currentRecipeValue >= minRequirement) &&
(maxRequirement === undefined || currentRecipeValue <= maxRequirement));
compareResult.value.push({
nutrientName: name,
currentRecipeValue,
minRequirement: minRequirement,
maxRequirement: maxRequirement,
isMet: isMet,
});
});
}
} catch (err) {
console.error("对比失败:", err);
compareError.value = err.message || '未知错误';
ElMessage.error('对比失败: ' + (err.message || '未知错误'));
} finally {
comparing.value = false;
}
};
watch(() => props.visible, (newVal) => {
if (newVal) {
fetchRecipeList();
fetchPigTypeList();
fetchNutrientsDescriptions(); // 新增:在对话框显示时获取营养素描述
}
}, { immediate: true });
return {
compareType,
recipeList,
selectedCompareRecipeId,
pigTypeList,
selectedPigTypeId,
comparing,
compareError,
compareResult,
compareRecipeName,
pigTypeName,
handleClose,
canCompare,
startCompare,
nutrientsDescriptionMap, // 新增:返回 nutrientsDescriptionMap
};
},
};
</script>
<style scoped>
.compare-form {
margin-bottom: 20px;
}
.loading-spinner, .error-message, .no-data-message {
padding: 20px;
text-align: center;
}
.dialog-footer {
text-align: right;
}
.table-wrapper {
margin: 0 auto; /* 水平居中 */
max-width: 100%; /* 确保不超过父容器宽度 */
}
</style>

View File

@@ -0,0 +1,326 @@
<template>
<el-dialog
:model-value="visible"
:title="`配方详情: ${recipe ? recipe.name : ''}` + (isEditing ? ' (编辑中)' : '')"
@close="handleClose"
width="70%"
top="5vh"
>
<div v-if="loading" class="loading-spinner">
<el-skeleton :rows="5" animated/>
</div>
<div v-else-if="error" class="error-message">
<el-alert
title="加载配方详情失败"
:description="error"
type="error"
show-icon
@close="error = null"
/>
</div>
<el-tabs v-else v-model="activeTab">
<el-tab-pane label="原料列表" name="ingredients">
<div v-if="!isEditing">
<el-table :data="ingredientDetails" style="width: 100%">
<el-table-column prop="name" label="原料名称"/>
<el-table-column prop="percentage" label="占比">
<template #default="scope">
{{ scope.row.percentage.toFixed(2) }}%
</template>
</el-table-column>
</el-table>
</div>
<div v-else>
<el-table :data="localIngredientDetails" style="width: 100%">
<el-table-column prop="name" label="原料名称"/>
<el-table-column label="占比">
<template #default="scope">
<el-input-number v-model="scope.row.percentage" :min="0" :max="100" :step="1" :precision="2"
@change="updateNutrientSummary"></el-input-number>
</template>
</el-table-column>
<el-table-column label="操作">
<template #default="scope">
<el-button type="danger" :icon="Delete" circle @click="removeIngredient(scope.$index)"></el-button>
</template>
</el-table-column>
</el-table>
<div class="add-ingredient-section">
<el-select v-model="newIngredientId" placeholder="选择要添加的原料" filterable style="flex-grow: 1;">
<el-option v-for="item in availableRawMaterials" :key="item.id" :label="item.name"
:value="item.id"></el-option>
</el-select>
<el-button type="primary" @click="addIngredient" style="margin-left: 10px;">添加原料</el-button>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="营养成分汇总" name="nutrients">
<el-table :data="nutrientSummary" style="width: 100%" ref="nutrientTableRef">
<el-table-column prop="name" label="营养素名称"/>
<el-table-column prop="value" label="总含量">
<template #default="scope">
{{ scope.row.value.toFixed(2) }}
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<template #footer>
<span class="dialog-footer">
<el-button v-if="!isEditing" @click="openAIReviewDialog">AI点评</el-button>
<el-button v-if="!isEditing" @click="openCompareDialog">对比</el-button>
<el-button v-if="!isEditing" @click="handleEdit">编辑配方</el-button>
<el-button v-else @click="handleCancelEdit">取消</el-button>
<el-button v-if="isEditing" type="primary" @click="handleSaveRecipe">保存</el-button>
<el-button v-else @click="handleClose">关闭</el-button>
</span>
</template>
<RecipeCompareDialog
v-model:visible="compareDialogVisible"
:current-recipe="recipe"
@close="compareDialogVisible = false"
/>
<AIRecipeReviewDialog
v-model:visible="aiReviewDialogVisible"
:recipe="recipe"
@cancel="aiReviewDialogVisible = false"
/>
</el-dialog>
</template>
<script>
import {ref, watch, nextTick, computed} from 'vue';
import {getRawMaterialById, getRawMaterials, updateRecipe, getRecipeById} from '../../api/feed';
import {ElMessage} from 'element-plus';
import {Delete} from '@element-plus/icons-vue';
import RecipeCompareDialog from './RecipeCompareDialog.vue';
import AIRecipeReviewDialog from './AIRecipeReviewDialog.vue';
export default {
name: 'RecipeDetailDialog',
props: {
visible: Boolean,
recipe: {
type: Object,
default: () => null,
},
},
emits: ['update:visible', 'recipe-updated'],
setup(props, {emit}) {
const isEditing = ref(false);
const activeTab = ref('ingredients');
const loading = ref(false);
const error = ref(null);
const ingredientDetails = ref([]);
const localIngredientDetails = ref([]);
const nutrientSummary = ref([]);
const nutrientTableRef = ref(null);
const allRawMaterials = ref([]);
const newIngredientId = ref(null);
const compareDialogVisible = ref(false);
const aiReviewDialogVisible = ref(false);
const handleClose = () => {
emit('update:visible', false);
};
const openCompareDialog = () => {
compareDialogVisible.value = true;
};
const openAIReviewDialog = () => {
aiReviewDialogVisible.value = true;
};
const calculateNutrientSummary = (ingredients) => {
const summary = new Map();
ingredients.forEach(ing => {
if (ing.raw_material_nutrients) {
ing.raw_material_nutrients.forEach(nutrient => {
const contribution = nutrient.value * (ing.percentage / 100);
if (summary.has(nutrient.nutrient_name)) {
summary.set(nutrient.nutrient_name, summary.get(nutrient.nutrient_name) + contribution);
} else {
summary.set(nutrient.nutrient_name, contribution);
}
});
}
});
return Array.from(summary, ([name, value]) => ({name, value}));
};
const availableRawMaterials = computed(() => {
const existingIngredientIds = new Set(localIngredientDetails.value.map(ing => ing.id));
return allRawMaterials.value.filter(material => !existingIngredientIds.has(material.id));
});
const fetchAndSetRecipeDetails = async (recipeId) => {
loading.value = true;
error.value = null;
try {
const recipeResponse = await getRecipeById(recipeId);
const latestRecipe = recipeResponse.data;
const rawMaterialPromises = latestRecipe.recipe_ingredients.map(ing => getRawMaterialById(ing.raw_material_id));
const rawMaterialResponses = await Promise.all(rawMaterialPromises);
const details = rawMaterialResponses.map((res, index) => ({
...res.data,
percentage: latestRecipe.recipe_ingredients[index].percentage,
}));
ingredientDetails.value = details;
localIngredientDetails.value = JSON.parse(JSON.stringify(details));
nutrientSummary.value = calculateNutrientSummary(details);
} catch (err) {
console.error("加载配方详情失败:", err);
error.value = err.message || '未知错误';
} finally {
loading.value = false;
}
};
watch(() => props.visible, async (newVal) => {
if (newVal && props.recipe) {
await fetchAndSetRecipeDetails(props.recipe.id);
} else {
ingredientDetails.value = [];
localIngredientDetails.value = [];
nutrientSummary.value = [];
activeTab.value = 'ingredients';
isEditing.value = false;
}
});
watch(activeTab, (newTab) => {
if (newTab === 'nutrients') {
nextTick(() => {
if (nutrientTableRef.value) {
nutrientTableRef.value.doLayout();
}
});
}
});
const updateNutrientSummary = () => {
nutrientSummary.value = calculateNutrientSummary(localIngredientDetails.value);
};
const handleEdit = async () => {
isEditing.value = true;
try {
const response = await getRawMaterials({page_size: 999});
if (response.data && response.data.list) {
allRawMaterials.value = response.data.list;
}
} catch (err) {
ElMessage.error('获取所有原料失败: ' + (err.message || '未知错误'));
}
};
const handleCancelEdit = () => {
isEditing.value = false;
localIngredientDetails.value = JSON.parse(JSON.stringify(ingredientDetails.value));
updateNutrientSummary();
};
const handleSaveRecipe = async () => {
const totalPercentage = localIngredientDetails.value.reduce((sum, ing) => sum + ing.percentage, 0);
if (totalPercentage > 100.001) {
ElMessage.error(`原料总占比不能超过100%,当前为${totalPercentage.toFixed(2)}%`);
return;
}
const recipeToSave = {
id: props.recipe.id,
name: props.recipe.name,
description: props.recipe.description,
recipe_ingredients: localIngredientDetails.value.map(ing => ({
raw_material_id: ing.id,
percentage: ing.percentage,
})),
};
try {
await updateRecipe(recipeToSave.id, recipeToSave);
ElMessage.success('配方更新成功');
isEditing.value = false;
emit('recipe-updated');
await fetchAndSetRecipeDetails(props.recipe.id);
} catch (err) {
ElMessage.error('保存配方失败: ' + (err.message || '未知错误'));
}
};
const removeIngredient = (index) => {
localIngredientDetails.value.splice(index, 1);
updateNutrientSummary();
};
const addIngredient = () => {
if (!newIngredientId.value) {
ElMessage.warning('请选择要添加的原料');
return;
}
const materialToAdd = allRawMaterials.value.find(m => m.id === newIngredientId.value);
if (materialToAdd && !localIngredientDetails.value.some(ing => ing.id === materialToAdd.id)) {
localIngredientDetails.value.push({...materialToAdd, percentage: 0});
newIngredientId.value = null;
updateNutrientSummary();
} else if (localIngredientDetails.value.some(ing => ing.id === materialToAdd.id)) {
ElMessage.warning('该原料已存在于配方中');
}
};
return {
activeTab,
loading,
error,
ingredientDetails,
localIngredientDetails,
nutrientSummary,
nutrientTableRef,
handleClose,
isEditing,
handleEdit,
handleCancelEdit,
handleSaveRecipe,
removeIngredient,
addIngredient,
availableRawMaterials,
newIngredientId,
updateNutrientSummary,
Delete,
compareDialogVisible,
openCompareDialog,
aiReviewDialogVisible,
openAIReviewDialog,
};
},
components: {
Delete,
RecipeCompareDialog,
AIRecipeReviewDialog,
}
};
</script>
<style scoped>
.loading-spinner, .error-message {
padding: 20px;
text-align: center;
}
.dialog-footer {
text-align: right;
}
.add-ingredient-section {
margin-top: 20px;
display: flex;
align-items: center;
}
</style>

View File

@@ -0,0 +1,175 @@
<template>
<el-dialog
:model-value="visible"
:title="title"
@close="handleClose"
:close-on-click-modal="false"
width="600px"
>
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="120px"
@submit.prevent
>
<el-form-item label="配方名" prop="name">
<el-input v-model="formData.name" />
</el-form-item>
<el-form-item label="配方简介" prop="description">
<el-input v-model="formData.description" type="textarea" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="loading">确定</el-button>
</span>
</template>
</el-dialog>
</template>
<script>
import { ref, reactive, computed, watch, nextTick } from 'vue';
import { ElMessage } from 'element-plus';
import { createRecipe, updateRecipe } from '../../api/feed';
export default {
name: 'RecipeForm',
props: {
visible: {
type: Boolean,
default: false,
},
recipeData: {
type: Object,
default: () => ({}),
},
isEdit: {
type: Boolean,
default: false,
},
},
emits: ['update:visible', 'success', 'cancel'],
setup(props, { emit }) {
const formRef = ref(null);
const loading = ref(false);
const initialFormData = () => ({
id: '',
name: '',
description: '',
});
const formData = reactive(initialFormData());
const rules = {
name: [{ required: true, message: '请输入配方名', trigger: 'blur' }],
};
const title = computed(() => {
return props.isEdit ? '编辑配方' : '新增配方';
});
const handleClose = () => {
emit('update:visible', false);
emit('cancel');
// 重置表单
Object.assign(formData, initialFormData());
nextTick(() => {
if (formRef.value) {
formRef.value.resetFields();
}
});
};
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (valid) {
loading.value = true;
try {
const submitData = {
name: formData.name,
description: formData.description,
};
if (props.isEdit) {
await updateRecipe(formData.id, submitData);
} else {
await createRecipe(submitData);
}
emit('success');
handleClose();
} catch (error) {
console.error('保存配方失败:', error);
ElMessage.error(
props.isEdit ? '编辑配方失败: ' + (error.message || '未知错误') : '创建配方失败: ' + (error.message || '未知错误')
);
} finally {
loading.value = false;
}
}
});
};
watch(
() => props.recipeData,
(newVal) => {
if (newVal && Object.keys(newVal).length > 0) {
// 填充表单数据
formData.id = newVal.id;
formData.name = newVal.name;
formData.description = newVal.description;
} else {
// 重置表单数据到初始状态 (新增模式)
Object.assign(formData, initialFormData());
}
nextTick(() => {
if (formRef.value) {
formRef.value.clearValidate();
}
});
},
{ immediate: true }
);
watch(
() => props.visible,
(newVal) => {
if (newVal && !props.isEdit) {
// 如果是新增模式且对话框打开,重置表单
Object.assign(formData, initialFormData());
nextTick(() => {
if (formRef.value) {
formRef.value.clearValidate();
}
});
}
},
{ immediate: true }
);
return {
formRef,
loading,
formData,
rules,
title,
handleClose,
handleSubmit,
};
},
};
</script>
<style scoped>
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>

View File

@@ -0,0 +1,50 @@
<template>
<el-table
:data="recipes"
style="width: 100%"
:fit="true"
table-layout="auto"
row-key="id"
:highlight-current-row="false"
:scrollbar-always-on="true"
>
<el-table-column prop="id" label="ID" width="80"/>
<el-table-column prop="name" label="配方名" width="120"/>
<el-table-column label="原料种类数" align="center" width="150">
<template #default="scope">
<span>{{ scope.row.recipe_ingredients ? scope.row.recipe_ingredients.length : 0 }} </span>
<el-button
type="primary"
link
@click="$emit('show-details', scope.row)"
style="padding: 0; vertical-align: baseline;"
>
[点击查看详情]
</el-button>
</template>
</el-table-column>
<el-table-column prop="description" label="配方简介"/>
<el-table-column label="操作" width="250" align="center">
<template #default="scope">
<el-button size="small" type="primary" @click="$emit('ai-review', scope.row)">AI点评</el-button>
<el-button size="small" @click="$emit('edit', scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="$emit('delete', scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</template>
<script>
export default {
name: 'RecipeTable',
props: {
recipes: {
type: Array,
required: true,
default: () => []
}
},
// 声明新的ai-review事件
emits: ['edit', 'delete', 'show-details', 'ai-review']
};
</script>

View File

@@ -0,0 +1,258 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="400px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="80px">
<el-form-item label="原料名称">
<el-input v-model="rawMaterial.raw_material_name" disabled></el-input>
</el-form-item>
<el-form-item label="操作类型">
<el-radio-group v-model="form.operationType">
<el-radio label="in">存入</el-radio>
<el-radio label="out">取出</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="变更类型" prop="source_type">
<el-select v-model="form.source_type" placeholder="请选择变更类型" style="width: 100%;">
<el-option
v-for="item in availableSourceTypes"
:key="item.value"
:label="item.label"
:value="item.value"
></el-option>
</el-select>
</el-form-item>
<el-form-item label="变动数量" prop="change_amount">
<div class="amount-unit-wrapper">
<el-input-number
v-model="displayAmount"
:min="computedMin"
:precision="computedPrecision"
:step="computedStep"
controls-position="right"
class="amount-input"
></el-input-number>
<el-select v-model="unit" placeholder="单位" class="unit-select">
<el-option label="g" value="g"></el-option>
<el-option label="kg" value="kg"></el-option>
</el-select>
</div>
</el-form-item>
<el-form-item label="备注" prop="remarks">
<el-input v-model="form.remarks" type="textarea" :rows="2"></el-input>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">确认</el-button>
</span>
</template>
</el-dialog>
</template>
<script>
import { ref, reactive, computed, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { InventoryApi } from '../../api/inventory';
import { StockLogSourceType } from '../../enums'; // 导入 StockLogSourceType
export default {
name: 'StockAdjustmentDialog',
props: {
visible: {
type: Boolean,
default: false,
},
rawMaterial: {
type: Object,
default: () => ({}),
},
},
emits: ['update:visible', 'success'],
setup(props, { emit }) {
const formRef = ref(null);
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val),
});
const unit = ref('g'); // 默认单位为克
const form = reactive({
operationType: 'in', // 默认存入
source_type: StockLogSourceType.MANUAL, // 默认变更类型
change_amount: 1000, // 内部始终以克为单位
remarks: '',
});
const dialogTitle = computed(() => {
return form.operationType === 'in' ? '存入库存' : '取出库存';
});
// 定义存入和取出对应的变更类型
const inSourceTypes = [
{ value: StockLogSourceType.PURCHASE, label: '采购入库' },
{ value: StockLogSourceType.FERMENT_END, label: '发酵入库' },
{ value: StockLogSourceType.MANUAL, label: '手动盘点' },
];
const outSourceTypes = [
{ value: StockLogSourceType.FEEDING, label: '饲喂出库' },
{ value: StockLogSourceType.DETERIORATE, label: '变质出库' },
{ value: StockLogSourceType.SALE, label: '售卖出库' },
{ value: StockLogSourceType.MISCELLANEOUS, label: '杂用领取' },
{ value: StockLogSourceType.FERMENT_START, label: '发酵出库' },
{ value: StockLogSourceType.MANUAL, label: '手动盘点' },
];
// 根据操作类型动态计算可用的变更类型
const availableSourceTypes = computed(() => {
const types = form.operationType === 'in' ? inSourceTypes : outSourceTypes;
// 如果当前选中的 source_type 不在新的可用列表中,则重置为第一个可用类型或 MANUAL
if (!types.some(item => item.value === form.source_type)) {
form.source_type = types.length > 0 ? types[0].value : StockLogSourceType.MANUAL;
}
return types;
});
// 计算属性:用于 el-input-number 的显示值和输入值转换
const displayAmount = computed({
get() {
if (unit.value === 'kg') {
return form.change_amount / 1000;
}
return form.change_amount;
},
set(val) {
if (unit.value === 'kg') {
form.change_amount = val * 1000;
} else {
form.change_amount = val;
}
},
});
// 计算属性el-input-number 的步长
const computedStep = computed(() => {
return unit.value === 'kg' ? 0.1 : 100;
});
// 计算属性el-input-number 的精度
const computedPrecision = computed(() => {
return unit.value === 'kg' ? 2 : 0;
});
// 计算属性el-input-number 的最小值
const computedMin = computed(() => {
return unit.value === 'kg' ? 0.001 : 1;
});
watch(() => props.visible, (newVal) => {
if (newVal) {
// 弹窗打开时重置表单和单位
form.change_amount = 1000; // 默认1000克
form.remarks = '';
form.operationType = 'in'; // 默认存入
form.source_type = StockLogSourceType.MANUAL; // 默认变更类型
unit.value = 'g'; // 默认单位为克
formRef.value?.clearValidate();
}
});
const rules = {
source_type: [
{ required: true, message: '请选择变更类型', trigger: 'change' },
],
change_amount: [
{
required: true,
message: '请输入变动数量',
trigger: 'blur',
// 验证器直接作用于内部的 form.change_amount (克)
validator: (rule, value, callback) => {
if (form.change_amount === null || form.change_amount === undefined || form.change_amount <= 0) {
callback(new Error('数量必须大于0'));
} else {
callback();
}
},
},
],
};
const handleSubmit = async () => {
try {
await formRef.value.validate();
let finalChangeAmount = form.change_amount; // form.change_amount 已经是克
if (form.operationType === 'out') {
finalChangeAmount = -finalChangeAmount;
// 检查是否超出库存 (props.rawMaterial.stock 也是克)
if (props.rawMaterial.stock + finalChangeAmount < 0) {
ElMessage.error('取出数量不能大于当前库存!');
return;
}
}
const data = {
raw_material_id: props.rawMaterial.raw_material_id,
change_amount: finalChangeAmount,
source_type: form.source_type, // 添加 source_type
remarks: form.remarks,
};
await InventoryApi.adjustStock(data);
ElMessage.success(`${form.operationType === 'in' ? '存入' : '取出'}成功!`);
emit('success');
dialogVisible.value = false;
} catch (error) {
if (error !== false) { // 阻止表单验证失败时的错误提示
ElMessage.error('操作失败: ' + (error.message || '未知错误'));
}
}
};
const handleClose = () => {
dialogVisible.value = false;
formRef.value?.resetFields();
};
return {
formRef,
dialogVisible,
dialogTitle,
form,
rules,
handleSubmit,
handleClose,
unit,
displayAmount,
computedStep,
computedPrecision,
computedMin,
availableSourceTypes, // 暴露给模板
};
},
};
</script>
<style scoped>
.amount-unit-wrapper {
display: flex;
align-items: center;
width: 100%;
}
.amount-input {
flex: 1; /* 让输入框占据剩余空间 */
margin-right: 10px; /* 输入框和选择器之间的间距 */
}
.unit-select {
width: 80px; /* 固定单位选择器的宽度 */
}
</style>

View File

@@ -0,0 +1,281 @@
<template>
<div>
<el-table
ref="mainTable"
:data="stockList"
style="width: 100%"
v-loading="loading"
row-key="raw_material_id"
@expand-change="handleExpandChange"
@row-click="handleRowClick"
>
<el-table-column type="expand">
<template #default="props">
<div style="padding: 10px 20px;">
<h4 style="margin-bottom: 16px;">历史操作记录 ({{ props.row.raw_material_name }})</h4>
<el-table :data="props.row.stock_logs" border v-loading="props.row.stock_log_loading">
<el-table-column prop="happened_at" label="操作时间" width="180">
<template #default="scope">
{{ formatRFC3339(scope.row.happened_at) }}
</template>
</el-table-column>
<el-table-column prop="source_type" label="操作类型" width="120">
<template #default="scope">
{{ getStockLogSourceTypeLabel(scope.row.source_type) }}
</template>
</el-table-column>
<el-table-column prop="change_amount" label="变动数量" width="120">
<template #default="scope">
{{ formatChangeAmount(scope.row.change_amount) }}
</template>
</el-table-column>
<el-table-column prop="before_quantity" label="变动前库存" width="120">
<template #default="scope">
{{ formatWeight(scope.row.before_quantity) }}
</template>
</el-table-column>
<el-table-column prop="after_quantity" label="变动后库存" width="120">
<template #default="scope">
{{ formatWeight(scope.row.after_quantity) }}
</template>
</el-table-column>
<el-table-column prop="remarks" label="备注"></el-table-column>
</el-table>
<el-pagination
v-if="props.row.stock_log_pagination && props.row.stock_log_pagination.total > 0"
style="margin-top: 20px;"
:current-page="props.row.stock_log_pagination.page"
:page-size="props.row.stock_log_pagination.page_size"
:total="props.row.stock_log_pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="(size) => handleStockLogSizeChange(props.row, size)"
@current-change="(page) => handleStockLogCurrentChange(props.row, page)"
></el-pagination>
</div>
</template>
</el-table-column>
<el-table-column prop="raw_material_name" label="原料名称"></el-table-column>
<el-table-column prop="stock" label="当前库存">
<template #default="scope">
{{ formatWeight(scope.row.stock) }}
</template>
</el-table-column>
<el-table-column prop="last_updated" label="最后更新时间">
<template #default="scope">
{{ formatRFC3339(scope.row.last_updated) }}
</template>
</el-table-column>
<el-table-column prop="last_operation_source_type" label="上次操作类型">
<template #default="scope">
{{ getStockLogSourceTypeLabel(scope.row.last_operation_source_type) }}
</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="scope">
<el-button size="small" type="primary" @click="handleAdjust(scope.row)">调整库存</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="pagination.page"
:page-sizes="[10, 20, 50, 100]"
:page-size="pagination.page_size"
layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total">
</el-pagination>
</div>
</template>
<script>
import { ref, onMounted, watch, nextTick } from 'vue';
import { InventoryApi } from '../../api/inventory';
import { ElMessage } from 'element-plus';
import { formatRFC3339, formatWeight, formatChangeAmount } from '../../utils/format'; // 导入 formatChangeAmount
import { getStockLogSourceTypeLabel } from '../../enums'; // 导入 getStockLogSourceTypeLabel
export default {
name: 'StockListTable',
props: {
stockFilter: {
type: String,
default: 'all', // 默认值与父组件保持一致
},
searchRawMaterialName: {
type: String,
default: '',
},
},
emits: ['adjust-stock'], // 声明组件将发出的事件
setup(props, { expose, emit }) { // 接收 emit 函数
const stockList = ref([]);
const loading = ref(false);
const pagination = ref({
page: 1,
page_size: 10,
total: 0,
});
// el-table 的引用,用于控制行的展开/折叠
const mainTable = ref(null);
const fetchStockList = async (filter = props.stockFilter, rawMaterialName = props.searchRawMaterialName) => {
loading.value = true;
try {
const params = {
page: pagination.value.page,
page_size: pagination.value.page_size,
};
if (filter === 'in_stock') {
params.has_stock = true;
}
if (rawMaterialName) {
params.raw_material_name = rawMaterialName;
}
const res = await InventoryApi.getCurrentStockList(params);
// 初始化 stock_logs 和 stock_log_pagination
stockList.value = res.data.list.map(item => ({
...item,
stock_logs: [], // 存储历史操作记录
stock_log_loading: false, // 历史操作记录的加载状态
stock_log_pagination: { // 历史操作记录的分页信息
page: 1,
page_size: 10,
total: 0,
},
}));
pagination.value.total = res.data.pagination.total;
} catch (error) {
ElMessage.error('获取库存列表失败');
} finally {
loading.value = false;
}
};
const handleSizeChange = (val) => {
pagination.value.page_size = val;
fetchStockList();
};
const handleCurrentChange = (val) => {
pagination.value.page = val;
fetchStockList();
};
// 处理库存调整操作
const handleAdjust = (row) => {
emit('adjust-stock', row); // 只传递行数据
};
/**
* 处理行展开/折叠事件
* @param {object} row - 当前行数据
* @param {Array<object>} expandedRows - 所有已展开的行数据
*/
const handleExpandChange = async (row, expandedRows) => {
const isExpanded = expandedRows.some(r => r.raw_material_id === row.raw_material_id);
// 仅在展开时且数据未加载时才请求
if (isExpanded) {
// 确保每次展开都重新加载第一页数据,或者根据当前分页状态加载
// 如果是第一次展开,或者用户手动切换了分页,则需要重新获取
await fetchStockLogs(row);
}
};
/**
* 获取指定原料的历史操作记录
* @param {object} row - 当前原料行数据
*/
const fetchStockLogs = async (row) => {
row.stock_log_loading = true;
try {
const params = {
raw_material_id: row.raw_material_id,
page: row.stock_log_pagination.page,
page_size: row.stock_log_pagination.page_size,
order_by: 'happened_at DESC', // 按时间倒序
};
const res = await InventoryApi.getStockLogList(params);
if (res.data) {
row.stock_logs = res.data.list;
row.stock_log_pagination.total = res.data.pagination.total;
}
} catch (error) {
console.error('获取库存历史操作记录失败:', error);
ElMessage.error('获取库存历史操作记录失败');
} finally {
row.stock_log_loading = false;
}
};
/**
* 处理历史操作记录分页的每页大小变化
* @param {object} row - 当前原料行数据
* @param {number} size - 新的每页大小
*/
const handleStockLogSizeChange = async (row, size) => {
row.stock_log_pagination.page_size = size;
row.stock_log_pagination.page = 1; // 重置页码到第一页
await fetchStockLogs(row);
};
/**
* 处理历史操作记录分页的当前页码变化
* @param {object} row - 当前原料行数据
* @param {number} page - 新的页码
*/
const handleStockLogCurrentChange = async (row, page) => {
row.stock_log_pagination.page = page;
await fetchStockLogs(row);
};
// 处理行点击事件,用于切换行的展开状态
const handleRowClick = (row, column) => {
// 如果点击的是操作列,则不执行任何操作
if (column && column.label === '操作') {
return;
}
// 否则,切换行的展开状态
mainTable.value.toggleRowExpansion(row);
};
onMounted(() => {
fetchStockList();
});
watch(() => props.stockFilter, (newFilter) => {
pagination.value.page = 1; // 筛选条件变化时重置页码
fetchStockList(newFilter, props.searchRawMaterialName);
});
watch(() => props.searchRawMaterialName, (newRawMaterialName) => {
pagination.value.page = 1; // 搜索条件变化时重置页码
fetchStockList(props.stockFilter, newRawMaterialName);
});
// 暴露 fetchStockList 方法给父组件
expose({
fetchStockList,
});
return {
stockList,
loading,
pagination,
mainTable, // 暴露 mainTable ref
handleSizeChange,
handleCurrentChange,
formatRFC3339,
formatWeight,
formatChangeAmount, // 暴露 formatChangeAmount
handleAdjust,
getStockLogSourceTypeLabel,
handleExpandChange,
handleStockLogSizeChange,
handleStockLogCurrentChange,
handleRowClick,
};
},
};
</script>

View File

@@ -64,6 +64,46 @@ export const LogChangeType = {
CORRECTION: '盘点校正',
};
/**
* 库存变动来源类型
* @enum {string}
*/
export const StockLogSourceType = {
PURCHASE: '采购入库',
FEEDING: '饲喂出库',
DETERIORATE: '变质出库',
SALE: '售卖出库',
MISCELLANEOUS: '杂用领取',
MANUAL: '手动盘点',
FERMENT_START: '发酵出库', // 原料投入发酵,从库存中扣除
FERMENT_END: '发酵入库', // 发酵料产出,作为新原料计入库存
};
/**
* 根据库存变动来源的键或值获取其对应的中文标签。
* @param {string} sourceTypeKeyOrValue - 库存变动来源的键 (如 "PURCHASE") 或值 (如 "采购入库")。
* @returns {string} 对应的中文标签,如果未找到则返回 '--'。
*/
export function getStockLogSourceTypeLabel(sourceTypeKeyOrValue) {
if (!sourceTypeKeyOrValue) {
return '--';
}
// 尝试直接通过键查找
if (StockLogSourceType[sourceTypeKeyOrValue]) {
return StockLogSourceType[sourceTypeKeyOrValue];
}
// 尝试通过值反向查找
for (const key in StockLogSourceType) {
if (StockLogSourceType[key] === sourceTypeKeyOrValue) {
return StockLogSourceType[key];
}
}
return '--';
}
/**
* 用药原因
* @enum {string}
@@ -212,6 +252,7 @@ export const SensorType = {
TEMPERATURE: '温度',
HUMIDITY: '湿度',
WEIGHT: '重量',
ONLINE_STATUS: '在线状态',
};
/**
@@ -223,10 +264,12 @@ export const TaskType = {
WAITING: '等待', // 等待任务
RELEASE_FEED_WEIGHT: '下料', // 下料口释放指定重量任务
FULL_COLLECTION: '全量采集', // 新增的全量采集任务
HEARTBEAT: '心跳检测', // 区域主控心跳检测任务
ALARM_NOTIFICATION: '告警通知', // 告警通知任务
NOTIFICATION_REFRESH: '通知刷新', // 通知刷新任务
DEVICE_THRESHOLD_CHECK: '设备阈值检查', // 设备阈值检查任务
AREA_COLLECTOR_THRESHOLD_CHECK: '区域阈值检查', // 区域阈值检查任务
OTA_CHECK: 'OTA升级检查任务', // OTA升级超时检查任务
};
/**
@@ -263,6 +306,7 @@ export const ZapcoreLevel = {
PANIC: 4,
FATAL: 5,
INVALID: 6,
NUM_LEVELS: 7, // 新增的级别数量
};
/**
@@ -289,6 +333,15 @@ export const AlarmSourceType = {
SYSTEM: '系统',
};
/**
* AI模型
* @enum {string}
*/
export const AIModel = {
AI_MODEL_NONE: 'None',
AI_MODEL_GEMINI: 'Gemini',
};
/**
* 操作符
* @enum {string}
@@ -315,3 +368,21 @@ export const SeverityLevel = {
PANIC: 'panic',
FATAL: 'fatal',
};
/**
* OTA任务状态
* @enum {string}
*/
export const OTATaskStatus = {
PENDING: '待开始', // 任务已创建,等待下发
IN_PROGRESS: '进行中', // 任务已下发,设备正在处理
SUCCESS: '成功', // 设备报告升级成功,新固件已运行
ALREADY_UP_TO_DATE: '版本已是最新', // 设备报告版本已是最新,未执行升级
FAILED_PREPARATION: '准备升级失败', // 平台在解压、生成清单等文件操作阶段发生错误
FAILED_PRE_CHECK: '预检失败', // 设备报告升级前检查失败 (如拒绝降级、准备分区失败)
FAILED_DOWNLOAD: '下载或校验失败', // 设备报告文件下载或校验失败 (包括清单文件和固件文件)
FAILED_ROLLBACK: '固件回滚', // 新固件启动失败,设备自动回滚
TIMED_OUT: '超时', // 平台在超时后仍未收到最终报告
PLATFORM_ERROR: '平台内部错误', // 平台处理过程中发生的非设备报告错误
STOPPED: '手动停止', // 手动停止
};

View File

@@ -109,6 +109,18 @@
</el-icon>
<template #title>品种管理</template>
</el-menu-item>
<el-menu-item index="/feed/recipes">
<el-icon>
<Tickets/>
</el-icon>
<template #title>配方管理</template>
</el-menu-item>
<el-menu-item index="/inventory/stock">
<el-icon>
<Tickets/>
</el-icon>
<template #title>库存管理</template>
</el-menu-item>
</el-sub-menu>
@@ -438,7 +450,7 @@ export default {
const activeMenu = computed(() => {
const path = route.path;
if (path.startsWith('/monitor') || path.startsWith('/pms') || path.startsWith('/devices') || path.startsWith('/device-templates') || path.startsWith('/alarms') || path.startsWith('/feed')) {
if (path.startsWith('/monitor') || path.startsWith('/pms') || path.startsWith('/devices') || path.startsWith('/device-templates') || path.startsWith('/alarms') || path.startsWith('/feed') || path.startsWith('/inventory')) {
return path;
}
return route.path;

View File

@@ -1,3 +1,27 @@
// 全局屏蔽 ResizeObserver 错误
// 这是一个常见的 workaround用于处理某些环境下 ResizeObserver 导致的循环限制错误。
// 它通过包装 ResizeObserver 的回调函数来捕获并阻止错误向上冒泡,但不会修复根本原因。
if (typeof window !== 'undefined' && window.ResizeObserver) {
const originalResizeObserver = window.ResizeObserver;
window.ResizeObserver = class ResizeObserver extends originalResizeObserver {
constructor(callback) {
super((entries, observer) => {
// 使用 requestAnimationFrame 延迟执行回调,以避免 ResizeObserver loop limit exceeded 错误
window.requestAnimationFrame(() => {
try {
callback(entries, observer);
} catch (error) {
// 捕获 ResizeObserver 错误,并打印到控制台,防止应用崩溃
console.error('ResizeObserver 错误被捕获:', error);
// 如果需要,可以在这里选择断开观察者,以防止进一步的错误
// observer.disconnect();
}
});
});
}
};
}
import {createApp} from 'vue';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';

View File

@@ -27,6 +27,9 @@ import RawMaterialList from '../views/feed/RawMaterialList.vue';
import NutrientList from '../views/feed/NutrientList.vue'; // 修正拼写错误
import PigAgeStageList from '../views/feed/PigAgeStageList.vue'; // 导入 PigAgeStageList 组件
import PigBreedList from '../views/feed/PigBreedList.vue'; // 导入 PigBreedList 组件
import RecipeList from '../views/feed/RecipeList.vue';
import StockManagement from '../views/inventory/StockManagement.vue';
const routes = [
{path: '/', component: Home, meta: {requiresAuth: true, title: '系统首页'}},
@@ -42,6 +45,9 @@ const routes = [
{path: '/feed/nutrients', component: NutrientList, meta: {requiresAuth: true, title: '营养管理'}},
{path: '/feed/pig-age-stages', component: PigAgeStageList, meta: {requiresAuth: true, title: '年龄阶段管理'}}, // 添加年龄阶段管理路由
{path: '/feed/pig-breeds', component: PigBreedList, meta: {requiresAuth: true, title: '品种管理'}}, // 添加品种管理路由
{path: '/feed/recipes', component: RecipeList, meta: {requiresAuth: true, title: '配方管理'}},
{path: '/inventory/stock', component: StockManagement, meta: {requiresAuth: true, title: '库存管理'}},
{path: '/monitor/device-command-logs', component: DeviceCommandLogView, meta: {requiresAuth: true, title: '设备命令日志'}},
{path: '/monitor/medication-logs', component: MedicationLogsView, meta: {requiresAuth: true, title: '用药记录'}},
{path: '/monitor/notifications', component: NotificationLogView, meta: {requiresAuth: true, title: '通知记录'}},

View File

@@ -4,8 +4,8 @@
* @returns {string} - 格式化后的字符串,如果输入无效则返回空字符串或提示
*/
export function formatRFC3339(rfc3339String) {
if (!rfc3339String) {
return '--'; // 或者返回空字符串 ''
if (!rfc3339String || rfc3339String.startsWith('0001-01-01')) {
return '--';
}
try {
@@ -18,5 +18,42 @@ export function formatRFC3339(rfc3339String) {
}
}
/**
* 将以克为单位的重量转换为更合适的单位(克或千克)并格式化
* @param {number} grams - 以克为单位的重量
* @returns {string} - 格式化后的重量字符串 (例如 "1.5 kg" 或 "500 g")
*/
export function formatWeight(grams) {
if (typeof grams !== 'number' || isNaN(grams)) {
return '--';
}
if (grams >= 1000) {
return (grams / 1000).toFixed(2) + ' kg';
} else {
return grams + ' g';
}
}
/**
* 格式化库存变动数量,显示正负号和单位 (kg 或 g)
* @param {number} changeAmount - 变动数量,单位为克 (正数入库,负数出库)
* @returns {string} - 格式化后的变动数量字符串 (例如 "+1.5 kg", "-500 g")
*/
export function formatChangeAmount(changeAmount) {
if (typeof changeAmount !== 'number' || isNaN(changeAmount)) {
return '--';
}
// 明确处理正号、负号和零
const sign = changeAmount > 0 ? '+' : (changeAmount < 0 ? '-' : '');
const absoluteAmount = Math.abs(changeAmount);
if (absoluteAmount >= 1000) {
return sign + (absoluteAmount / 1000).toFixed(2) + ' kg';
} else {
return sign + absoluteAmount + ' g';
}
}
// 你未来还可以添加其他全局格式化函数
// export function formatCurrency(number) { ... }

View File

@@ -37,7 +37,7 @@
<el-dialog
v-model="showNutrientDialog"
title="修改营养成分"
width="600px"
width="700px"
:close-on-click-modal="false"
@close="handleNutrientCancel"
>

View File

@@ -0,0 +1,244 @@
<template>
<div class="recipe-list">
<el-card>
<template #header>
<div class="card-header">
<div class="title-container">
<h2 class="page-title">配方管理</h2>
<el-button type="text" @click="loadRecipes" class="refresh-btn" title="刷新配方列表">
<el-icon :size="20"><Refresh /></el-icon>
</el-button>
</div>
<div>
<el-button type="primary" @click="addRecipe">新增配方</el-button>
<el-button type="success" @click="openGenerateRecipeDialog">一键生成配方</el-button>
</div>
</div>
</template>
<!-- 加载状态 -->
<div v-if="loading" class="loading">
<el-skeleton animated />
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error">
<el-alert
title="获取配方数据失败"
:description="error"
type="error"
show-icon
closable
@close="error = null"
/>
<el-button type="primary" @click="loadRecipes" class="retry-btn">重新加载</el-button>
</div>
<!-- 配方列表 -->
<RecipeTable
v-else
:recipes="recipes"
@edit="editRecipe"
@delete="deleteRecipe"
@show-details="handleShowDetails"
@ai-review="openAIRecipeReviewDialog"
/>
</el-card>
<!-- 配方表单对话框 -->
<RecipeForm
v-model:visible="dialogVisible"
:recipe-data="currentRecipe"
:is-edit="isEdit"
@success="onRecipeSuccess"
@cancel="dialogVisible = false"
/>
<!-- 配方详情对话框 -->
<RecipeDetailDialog
v-model:visible="detailDialogVisible"
:recipe="selectedRecipe"
/>
<!-- 一键生成配方对话框 -->
<GenerateRecipeDialog
v-model:visible="generateRecipeDialogVisible"
@success="onGenerateRecipeSuccess"
@cancel="generateRecipeDialogVisible = false"
/>
<!-- AI点评配方对话框 -->
<AIRecipeReviewDialog
v-model:visible="aiReviewDialogVisible"
:recipe="selectedRecipeForAIReview"
@cancel="aiReviewDialogVisible = false"
/>
</div>
</template>
<script>
import { Refresh } from '@element-plus/icons-vue';
import { getRecipes, deleteRecipe as deleteRecipeApi } from '../../api/feed.js';
import RecipeTable from '../../components/feed/RecipeTable.vue';
import RecipeForm from '../../components/feed/RecipeForm.vue';
import RecipeDetailDialog from '../../components/feed/RecipeDetailDialog.vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import GenerateRecipeDialog from '../../components/feed/GenerateRecipeDialog.vue';
import AIRecipeReviewDialog from '../../components/feed/AIRecipeReviewDialog.vue'; // 引入新的AI点评组件
export default {
name: 'RecipeList',
components: {
RecipeTable,
RecipeForm,
RecipeDetailDialog,
Refresh,
GenerateRecipeDialog,
AIRecipeReviewDialog, // 注册新的AI点评组件
},
data() {
return {
recipes: [],
loading: false,
error: null,
dialogVisible: false,
currentRecipe: {},
isEdit: false,
detailDialogVisible: false,
selectedRecipe: null,
generateRecipeDialogVisible: false, // 控制一键生成配方弹窗的显示
aiReviewDialogVisible: false, // 控制AI点评弹窗的显示
selectedRecipeForAIReview: null, // 存储当前选择进行AI点评的配方
};
},
async mounted() {
await this.loadRecipes();
},
methods: {
async loadRecipes() {
this.loading = true;
this.error = null;
try {
const response = await getRecipes({ page: 1, page_size: 999, order_by: "id DESC" });
this.recipes = response.data.list || [];
} catch (err) {
this.error = err.message || '未知错误';
console.error('加载配方列表失败:', err);
} finally {
this.loading = false;
}
},
addRecipe() {
this.currentRecipe = {};
this.isEdit = false;
this.dialogVisible = true;
},
editRecipe(recipe) {
this.currentRecipe = JSON.parse(JSON.stringify(recipe));
this.isEdit = true;
this.dialogVisible = true;
},
async deleteRecipe(recipe) {
try {
await ElMessageBox.confirm(
`确认删除配方 "${recipe.name}" 吗?`,
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
);
await deleteRecipeApi(recipe.id);
ElMessage.success('删除成功');
await this.loadRecipes();
} catch (err) {
if (err !== 'cancel') {
ElMessage.error('删除失败: ' + (err.message || '未知错误'));
}
}
},
onRecipeSuccess() {
ElMessage.success(this.isEdit ? '配方更新成功' : '配方添加成功');
this.dialogVisible = false;
this.loadRecipes();
},
handleShowDetails(recipe) {
this.selectedRecipe = recipe;
this.detailDialogVisible = true;
},
/**
* 打开一键生成配方对话框
*/
openGenerateRecipeDialog() {
this.generateRecipeDialogVisible = true;
},
onGenerateRecipeSuccess(recipeName, recipeDescription) {
ElMessage.success(`配方 "${recipeName}" 生成成功: ${recipeDescription}`);
this.generateRecipeDialogVisible = false;
this.loadRecipes(); // 刷新配方列表
},
/**
* 打开AI点评配方对话框
* @param {object} recipe - 需要AI点评的配方对象
*/
openAIRecipeReviewDialog(recipe) {
this.selectedRecipeForAIReview = recipe;
this.aiReviewDialogVisible = true;
},
}
};
</script>
<style scoped>
.recipe-list {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 0;
}
.title-container {
display: flex;
align-items: center;
gap: 5px;
}
.page-title {
margin: 0;
font-size: 1.5rem;
font-weight: bold;
line-height: 1;
}
.refresh-btn {
color: black;
background-color: transparent;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border: none;
}
.loading {
padding: 20px 0;
}
.error {
padding: 20px 0;
text-align: center;
}
.retry-btn {
margin-top: 15px;
}
</style>

View File

@@ -0,0 +1,139 @@
<template>
<div class="stock-management-container">
<el-card>
<template #header>
<div class="card-header">
<div class="title-container">
<h2 class="page-title">库存管理</h2>
<el-button type="text" @click="refreshList" class="refresh-btn" title="刷新库存列表">
<el-icon :size="20"><Refresh /></el-icon>
</el-button>
</div>
<div class="filter-controls">
<el-input
v-model="searchRawMaterialName"
placeholder="按原料名称搜索"
clearable
@keyup.enter="refreshList"
style="width: 200px; margin-right: 10px;"
></el-input>
<el-select v-model="stockFilter" placeholder="筛选库存" @change="refreshList" style="width: 150px;">
<el-option label="所有原料" value="all"></el-option>
<el-option label="有库存的原料" value="in_stock"></el-option>
</el-select>
</div>
</div>
</template>
<stock-list-table
ref="stockListTableRef"
:stockFilter="stockFilter"
:searchRawMaterialName="searchRawMaterialName"
@adjust-stock="handleAdjustStock"
></stock-list-table>
</el-card>
<!-- 库存调整弹窗 -->
<stock-adjustment-dialog
v-model:visible="showAdjustmentDialog"
:rawMaterial="currentRawMaterialForAdjustment"
@success="refreshList"
></stock-adjustment-dialog>
</div>
</template>
<script>
import { ref } from 'vue';
import { Refresh } from '@element-plus/icons-vue';
import StockListTable from '../../components/inventory/StockListTable.vue';
import StockAdjustmentDialog from '../../components/inventory/StockAdjustmentDialog.vue'; // 导入库存调整弹窗组件
export default {
name: 'StockManagement',
components: {
StockListTable,
Refresh,
StockAdjustmentDialog, // 注册组件
},
setup() {
const stockListTableRef = ref(null);
const stockFilter = ref('all'); // 默认显示所有原料
const searchRawMaterialName = ref(''); // 搜索原料名称
// 库存调整弹窗相关
const showAdjustmentDialog = ref(false);
const currentRawMaterialForAdjustment = ref(null);
// currentOperationType 不再需要在这里管理,由 StockAdjustmentDialog 内部管理
const refreshList = () => {
if (stockListTableRef.value) {
stockListTableRef.value.fetchStockList(stockFilter.value, searchRawMaterialName.value);
}
};
/**
* 处理库存调整操作
* @param {object} rawMaterial - 当前操作的原料数据
*/
const handleAdjustStock = (rawMaterial) => {
currentRawMaterialForAdjustment.value = { ...rawMaterial };
showAdjustmentDialog.value = true;
};
return {
stockListTableRef,
stockFilter,
searchRawMaterialName,
refreshList,
// 库存调整弹窗相关
showAdjustmentDialog,
currentRawMaterialForAdjustment,
handleAdjustStock,
};
},
};
</script>
<style scoped>
.stock-management-container {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 0;
}
.title-container {
display: flex;
align-items: center;
gap: 5px;
}
.page-title {
margin: 0;
font-size: 1.5rem;
font-weight: bold;
line-height: 1;
}
.refresh-btn {
color: black;
background-color: transparent;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border: none;
}
.filter-controls {
display: flex;
align-items: center;
}
</style>