增加功能报价
This commit is contained in:
Generated
+300
@@ -25,6 +25,7 @@
|
|||||||
"pdfjs-dist": "^6.2.108",
|
"pdfjs-dist": "^6.2.108",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"pinyin-pro": "^3.29.4",
|
"pinyin-pro": "^3.29.4",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"tesseract.js": "^7.0.0",
|
"tesseract.js": "^7.0.0",
|
||||||
"v-viewer": "^3.0.11",
|
"v-viewer": "^3.0.11",
|
||||||
"vue": "^3.5.22",
|
"vue": "^3.5.22",
|
||||||
@@ -3306,6 +3307,42 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ansi-styles/node_modules/color-convert": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "~1.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/argparse": {
|
"node_modules/argparse": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
|
||||||
@@ -3495,6 +3532,15 @@
|
|||||||
"tslib": "^2.0.3"
|
"tslib": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/camelcase": {
|
||||||
|
"version": "5.3.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/canvg": {
|
"node_modules/canvg": {
|
||||||
"version": "3.0.11",
|
"version": "3.0.11",
|
||||||
"resolved": "https://registry.npmmirror.com/canvg/-/canvg-3.0.11.tgz",
|
"resolved": "https://registry.npmmirror.com/canvg/-/canvg-3.0.11.tgz",
|
||||||
@@ -3578,6 +3624,17 @@
|
|||||||
"node": ">= 10.0"
|
"node": ">= 10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cliui": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.0",
|
||||||
|
"wrap-ansi": "^6.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/codepage": {
|
"node_modules/codepage": {
|
||||||
"version": "1.15.0",
|
"version": "1.15.0",
|
||||||
"resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz",
|
"resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz",
|
||||||
@@ -4289,6 +4346,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decamelize": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/default-passive-events": {
|
"node_modules/default-passive-events": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/default-passive-events/-/default-passive-events-4.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/default-passive-events/-/default-passive-events-4.0.0.tgz",
|
||||||
@@ -4369,6 +4435,12 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dijkstrajs": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/docx-preview": {
|
"node_modules/docx-preview": {
|
||||||
"version": "0.3.7",
|
"version": "0.3.7",
|
||||||
"resolved": "https://registry.npmmirror.com/docx-preview/-/docx-preview-0.3.7.tgz",
|
"resolved": "https://registry.npmmirror.com/docx-preview/-/docx-preview-0.3.7.tgz",
|
||||||
@@ -4519,6 +4591,12 @@
|
|||||||
"vue": "^3.3.0"
|
"vue": "^3.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/entities": {
|
"node_modules/entities": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
|
||||||
@@ -4859,6 +4937,19 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/find-up": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"locate-path": "^5.0.0",
|
||||||
|
"path-exists": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.15.11",
|
"version": "1.15.11",
|
||||||
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||||
@@ -4981,6 +5072,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-caller-file": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-intrinsic": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
@@ -5519,6 +5619,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-generator-function": {
|
"node_modules/is-generator-function": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
||||||
@@ -5938,6 +6047,18 @@
|
|||||||
"url": "https://github.com/sponsors/antfu"
|
"url": "https://github.com/sponsors/antfu"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/locate-path": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-locate": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.23",
|
"version": "4.17.23",
|
||||||
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz",
|
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz",
|
||||||
@@ -6427,6 +6548,42 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/p-limit": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-try": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-locate": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-limit": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/p-try": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/p-try/-/p-try-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/package-manager-detector": {
|
"node_modules/package-manager-detector": {
|
||||||
"version": "1.7.0",
|
"version": "1.7.0",
|
||||||
"resolved": "https://registry.npmmirror.com/package-manager-detector/-/package-manager-detector-1.7.0.tgz",
|
"resolved": "https://registry.npmmirror.com/package-manager-detector/-/package-manager-detector-1.7.0.tgz",
|
||||||
@@ -6474,6 +6631,15 @@
|
|||||||
"integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==",
|
"integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/path-exists": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pathe": {
|
"node_modules/pathe": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz",
|
"resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz",
|
||||||
@@ -6587,6 +6753,15 @@
|
|||||||
"url-polyfill": "^1.1.13"
|
"url-polyfill": "^1.1.13"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/points-on-curve": {
|
"node_modules/points-on-curve": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmmirror.com/points-on-curve/-/points-on-curve-0.2.0.tgz",
|
"resolved": "https://registry.npmmirror.com/points-on-curve/-/points-on-curve-0.2.0.tgz",
|
||||||
@@ -6952,6 +7127,23 @@
|
|||||||
"integrity": "sha512-uxq2BMTdnKW7jDghpLJrczCd9KDOdyghFtEEpfomqMJkUM83/N+W7sFJPJ3AxBf0mJ3xtxAycl6NW8p6F53jEw==",
|
"integrity": "sha512-uxq2BMTdnKW7jDghpLJrczCd9KDOdyghFtEEpfomqMJkUM83/N+W7sFJPJ3AxBf0mJ3xtxAycl6NW8p6F53jEw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dijkstrajs": "^1.0.1",
|
||||||
|
"pngjs": "^5.0.0",
|
||||||
|
"yargs": "^15.3.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"qrcode": "bin/qrcode"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/quansync": {
|
"node_modules/quansync": {
|
||||||
"version": "0.2.11",
|
"version": "0.2.11",
|
||||||
"resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz",
|
"resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz",
|
||||||
@@ -7091,6 +7283,21 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-directory": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/require-main-filename": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/reusify": {
|
"node_modules/reusify": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz",
|
||||||
@@ -7317,6 +7524,12 @@
|
|||||||
"semver": "bin/semver"
|
"semver": "bin/semver"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-blocking": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/set-function-length": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
@@ -7552,6 +7765,20 @@
|
|||||||
"safe-buffer": "~5.1.0"
|
"safe-buffer": "~5.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string.prototype.trim": {
|
"node_modules/string.prototype.trim": {
|
||||||
"version": "1.2.10",
|
"version": "1.2.10",
|
||||||
"resolved": "https://registry.npmmirror.com/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz",
|
"resolved": "https://registry.npmmirror.com/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz",
|
||||||
@@ -7608,6 +7835,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/strip-literal": {
|
"node_modules/strip-literal": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-3.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-3.1.0.tgz",
|
||||||
@@ -8406,6 +8645,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which-module": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/which-typed-array": {
|
"node_modules/which-typed-array": {
|
||||||
"version": "1.1.20",
|
"version": "1.1.20",
|
||||||
"resolved": "https://registry.npmmirror.com/which-typed-array/-/which-typed-array-1.1.20.tgz",
|
"resolved": "https://registry.npmmirror.com/which-typed-array/-/which-typed-array-1.1.20.tgz",
|
||||||
@@ -8445,6 +8690,20 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wrap-ansi": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xlsx": {
|
"node_modules/xlsx": {
|
||||||
"version": "0.18.5",
|
"version": "0.18.5",
|
||||||
"resolved": "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz",
|
"resolved": "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz",
|
||||||
@@ -8524,6 +8783,47 @@
|
|||||||
"yjs": "^13.0.0"
|
"yjs": "^13.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/y18n": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/yargs": {
|
||||||
|
"version": "15.4.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz",
|
||||||
|
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^6.0.0",
|
||||||
|
"decamelize": "^1.2.0",
|
||||||
|
"find-up": "^4.1.0",
|
||||||
|
"get-caller-file": "^2.0.1",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"require-main-filename": "^2.0.0",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"which-module": "^2.0.0",
|
||||||
|
"y18n": "^4.0.0",
|
||||||
|
"yargs-parser": "^18.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs-parser": {
|
||||||
|
"version": "18.1.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||||
|
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"camelcase": "^5.0.0",
|
||||||
|
"decamelize": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yjs": {
|
"node_modules/yjs": {
|
||||||
"version": "13.6.29",
|
"version": "13.6.29",
|
||||||
"resolved": "https://registry.npmmirror.com/yjs/-/yjs-13.6.29.tgz",
|
"resolved": "https://registry.npmmirror.com/yjs/-/yjs-13.6.29.tgz",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"pdfjs-dist": "^6.2.108",
|
"pdfjs-dist": "^6.2.108",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"pinyin-pro": "^3.29.4",
|
"pinyin-pro": "^3.29.4",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"tesseract.js": "^7.0.0",
|
"tesseract.js": "^7.0.0",
|
||||||
"v-viewer": "^3.0.11",
|
"v-viewer": "^3.0.11",
|
||||||
"vue": "^3.5.22",
|
"vue": "^3.5.22",
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
/*************************************************
|
||||||
|
* 租户端「产品购买」相关接口
|
||||||
|
*************************************************/
|
||||||
|
|
||||||
|
/** 可购买产品列表(含功能节点与价格档位) */
|
||||||
|
export function getProductList(params) {
|
||||||
|
return request({
|
||||||
|
url: "/backend/product/list",
|
||||||
|
method: "get",
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按产品编码取详情 */
|
||||||
|
export function getProductDetailByCode(code) {
|
||||||
|
return request({
|
||||||
|
url: `/backend/product/detail/${code}`,
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下单(channel 为空表示线下/待平台开通) */
|
||||||
|
export function createProductOrder(data) {
|
||||||
|
return request({
|
||||||
|
url: "/backend/product/order/create",
|
||||||
|
method: "post",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询订单状态(已支付会自动开通) */
|
||||||
|
export function getProductOrderStatus(orderNo) {
|
||||||
|
return request({
|
||||||
|
url: "/backend/product/order/status",
|
||||||
|
method: "get",
|
||||||
|
params: { order_no: orderNo },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本租户购买记录 */
|
||||||
|
export function getMyProductOrders() {
|
||||||
|
return request({
|
||||||
|
url: "/backend/product/order/list",
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收银台可用支付渠道 */
|
||||||
|
export function getPaymentChannels() {
|
||||||
|
return request({
|
||||||
|
url: "/backend/payment/channels",
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -41,10 +41,12 @@
|
|||||||
<el-menu-item
|
<el-menu-item
|
||||||
v-if="!item.children || item.children.length === 0"
|
v-if="!item.children || item.children.length === 0"
|
||||||
:index="item.path || item.id.toString()"
|
:index="item.path || item.id.toString()"
|
||||||
|
:class="{ 'is-locked': item.locked }"
|
||||||
>
|
>
|
||||||
<i v-if="item.icon" :class="item.icon" class="menu-icon"></i>
|
<i v-if="item.icon" :class="item.icon" class="menu-icon"></i>
|
||||||
<template #title>
|
<template #title>
|
||||||
<span>{{ item.title }}</span>
|
<span>{{ item.title }}</span>
|
||||||
|
<i v-if="item.locked" class="fa-solid fa-lock lock-badge"></i>
|
||||||
</template>
|
</template>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
|
||||||
@@ -53,10 +55,12 @@
|
|||||||
v-else
|
v-else
|
||||||
:index="item.path || item.id.toString()"
|
:index="item.path || item.id.toString()"
|
||||||
:unique-opened="true"
|
:unique-opened="true"
|
||||||
|
:class="{ 'is-locked': item.locked }"
|
||||||
>
|
>
|
||||||
<template #title>
|
<template #title>
|
||||||
<i v-if="item.icon" :class="item.icon" class="menu-icon"></i>
|
<i v-if="item.icon" :class="item.icon" class="menu-icon"></i>
|
||||||
<span>{{ item.title }}</span>
|
<span>{{ item.title }}</span>
|
||||||
|
<i v-if="item.locked" class="fa-solid fa-lock lock-badge"></i>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 递归渲染子菜单 -->
|
<!-- 递归渲染子菜单 -->
|
||||||
@@ -64,10 +68,12 @@
|
|||||||
<el-menu-item
|
<el-menu-item
|
||||||
v-if="!child.children || child.children.length === 0"
|
v-if="!child.children || child.children.length === 0"
|
||||||
:index="child.path || child.id.toString()"
|
:index="child.path || child.id.toString()"
|
||||||
|
:class="{ 'is-locked': child.locked }"
|
||||||
>
|
>
|
||||||
<i v-if="child.icon" :class="child.icon" class="menu-icon"></i>
|
<i v-if="child.icon" :class="child.icon" class="menu-icon"></i>
|
||||||
<template #title>
|
<template #title>
|
||||||
<span>{{ child.title }}</span>
|
<span>{{ child.title }}</span>
|
||||||
|
<i v-if="child.locked" class="fa-solid fa-lock lock-badge"></i>
|
||||||
</template>
|
</template>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
|
||||||
@@ -75,10 +81,12 @@
|
|||||||
v-else
|
v-else
|
||||||
:index="child.path || child.id.toString()"
|
:index="child.path || child.id.toString()"
|
||||||
:unique-opened="true"
|
:unique-opened="true"
|
||||||
|
:class="{ 'is-locked': child.locked }"
|
||||||
>
|
>
|
||||||
<template #title>
|
<template #title>
|
||||||
<i v-if="child.icon" :class="child.icon" class="menu-icon"></i>
|
<i v-if="child.icon" :class="child.icon" class="menu-icon"></i>
|
||||||
<span>{{ child.title }}</span>
|
<span>{{ child.title }}</span>
|
||||||
|
<i v-if="child.locked" class="fa-solid fa-lock lock-badge"></i>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 继续递归渲染子菜单 -->
|
<!-- 继续递归渲染子菜单 -->
|
||||||
@@ -91,6 +99,7 @@
|
|||||||
!grandchild.children || grandchild.children.length === 0
|
!grandchild.children || grandchild.children.length === 0
|
||||||
"
|
"
|
||||||
:index="grandchild.path || grandchild.id.toString()"
|
:index="grandchild.path || grandchild.id.toString()"
|
||||||
|
:class="{ 'is-locked': grandchild.locked }"
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
v-if="grandchild.icon"
|
v-if="grandchild.icon"
|
||||||
@@ -99,6 +108,7 @@
|
|||||||
></i>
|
></i>
|
||||||
<template #title>
|
<template #title>
|
||||||
<span>{{ grandchild.title }}</span>
|
<span>{{ grandchild.title }}</span>
|
||||||
|
<i v-if="grandchild.locked" class="fa-solid fa-lock lock-badge"></i>
|
||||||
</template>
|
</template>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
|
||||||
@@ -106,6 +116,7 @@
|
|||||||
v-else
|
v-else
|
||||||
:index="grandchild.path || grandchild.id.toString()"
|
:index="grandchild.path || grandchild.id.toString()"
|
||||||
:unique-opened="true"
|
:unique-opened="true"
|
||||||
|
:class="{ 'is-locked': grandchild.locked }"
|
||||||
>
|
>
|
||||||
<template #title>
|
<template #title>
|
||||||
<i
|
<i
|
||||||
@@ -114,6 +125,7 @@
|
|||||||
class="menu-icon"
|
class="menu-icon"
|
||||||
></i>
|
></i>
|
||||||
<span>{{ grandchild.title }}</span>
|
<span>{{ grandchild.title }}</span>
|
||||||
|
<i v-if="grandchild.locked" class="fa-solid fa-lock lock-badge"></i>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 继续递归渲染... -->
|
<!-- 继续递归渲染... -->
|
||||||
@@ -125,6 +137,7 @@
|
|||||||
:index="
|
:index="
|
||||||
greatGrandchild.path || greatGrandchild.id.toString()
|
greatGrandchild.path || greatGrandchild.id.toString()
|
||||||
"
|
"
|
||||||
|
:class="{ 'is-locked': greatGrandchild.locked }"
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
v-if="greatGrandchild.icon"
|
v-if="greatGrandchild.icon"
|
||||||
@@ -133,6 +146,7 @@
|
|||||||
></i>
|
></i>
|
||||||
<template #title>
|
<template #title>
|
||||||
<span>{{ greatGrandchild.title }}</span>
|
<span>{{ greatGrandchild.title }}</span>
|
||||||
|
<i v-if="greatGrandchild.locked" class="fa-solid fa-lock lock-badge"></i>
|
||||||
</template>
|
</template>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
</template>
|
</template>
|
||||||
@@ -283,6 +297,7 @@ const processMenus = (menus) => {
|
|||||||
title: menu.title,
|
title: menu.title,
|
||||||
route: menu.path,
|
route: menu.path,
|
||||||
component_path: menu.component_path,
|
component_path: menu.component_path,
|
||||||
|
locked: !!menu.locked,
|
||||||
parentId: menu.pid || 0,
|
parentId: menu.pid || 0,
|
||||||
order: menu.sort || 0,
|
order: menu.sort || 0,
|
||||||
children: menu.children ? processMenus(menu.children) : [],
|
children: menu.children ? processMenus(menu.children) : [],
|
||||||
@@ -608,6 +623,18 @@ h3 {
|
|||||||
padding-left: 48px !important;
|
padding-left: 48px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 套餐未开通模块:锁定态置灰 + 锁图标
|
||||||
|
.el-menu-item.is-locked,
|
||||||
|
.el-sub-menu.is-locked > .el-sub-menu__title {
|
||||||
|
opacity: 0.65;
|
||||||
|
|
||||||
|
.lock-badge {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 暗色主题适配
|
// 暗色主题适配
|
||||||
html.dark & {
|
html.dark & {
|
||||||
.el-menu-item.is-active {
|
.el-menu-item.is-active {
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ function resolvePageComponent(menu) {
|
|||||||
return () => import('@/views/404/404.vue');
|
return () => import('@/views/404/404.vue');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 套餐未开通模块的锁定占位页组件 */
|
||||||
|
const lockedPageComponent = () => import('@/views/locked/index.vue');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 菜单子节点 -> 嵌套路由(path 相对 layoutAbsPath)
|
* 菜单子节点 -> 嵌套路由(path 相对 layoutAbsPath)
|
||||||
*/
|
*/
|
||||||
@@ -59,6 +62,20 @@ function nestedMenuToRoute(menu, layoutAbsPath) {
|
|||||||
componentPath: menu.component_path,
|
componentPath: menu.component_path,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 套餐未开通模块:渲染锁定占位页(子路由同样注册,保证深链接不 404)
|
||||||
|
if (menu.locked) {
|
||||||
|
const route = {
|
||||||
|
path: relPath,
|
||||||
|
name: `menu_${menu.id}`,
|
||||||
|
meta,
|
||||||
|
component: lockedPageComponent,
|
||||||
|
};
|
||||||
|
if (hasChildren) {
|
||||||
|
route.children = convertNestedMenuChildren(menu.children, childAbs);
|
||||||
|
}
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
|
||||||
// 既有自己的页面又有子菜单:套一层 EmptyLayout,避免父页面组件里没有 <router-view> 导致子路由无法渲染
|
// 既有自己的页面又有子菜单:套一层 EmptyLayout,避免父页面组件里没有 <router-view> 导致子路由无法渲染
|
||||||
if (hasChildren && ownPage) {
|
if (hasChildren && ownPage) {
|
||||||
return {
|
return {
|
||||||
@@ -128,6 +145,20 @@ export function convertMenusToRoutes(menus, parentPath = '') {
|
|||||||
componentPath: menu.component_path,
|
componentPath: menu.component_path,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 套餐未开通模块:渲染锁定占位页(子路由同样注册,保证深链接不 404)
|
||||||
|
if (menu.locked) {
|
||||||
|
const route = {
|
||||||
|
path: fullPath || menu.path || '',
|
||||||
|
name: `menu_${menu.id}`,
|
||||||
|
meta,
|
||||||
|
component: lockedPageComponent,
|
||||||
|
};
|
||||||
|
if (hasChildren) {
|
||||||
|
route.children = convertMenusToRoutes(menu.children, fullPath);
|
||||||
|
}
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
|
||||||
// 顶层:有页面 + 有子菜单 -> EmptyLayout + 默认子路由 + 相对 path 子路由
|
// 顶层:有页面 + 有子菜单 -> EmptyLayout + 默认子路由 + 相对 path 子路由
|
||||||
if (hasChildren && ownPage) {
|
if (hasChildren && ownPage) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -157,6 +157,13 @@ const staticRoutes = [
|
|||||||
path: "/apps/erp/dashborad",
|
path: "/apps/erp/dashborad",
|
||||||
redirect: "/apps/erp/dashboard"
|
redirect: "/apps/erp/dashboard"
|
||||||
},
|
},
|
||||||
|
// 产品购买页:home 未开通卡片「购买」按钮跳转,独立于 Main 框架展示
|
||||||
|
{
|
||||||
|
path: "/product/:code",
|
||||||
|
name: "ProductPurchase",
|
||||||
|
component: () => import("@/views/product/index.vue"),
|
||||||
|
meta: { requiresAuth: true, title: "产品购买", isStandalone: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/:pathMatch(.*)*",
|
path: "/:pathMatch(.*)*",
|
||||||
name: "NotFound",
|
name: "NotFound",
|
||||||
|
|||||||
@@ -71,16 +71,24 @@
|
|||||||
v-for="module in basicModules"
|
v-for="module in basicModules"
|
||||||
:key="module.path"
|
:key="module.path"
|
||||||
class="module-card"
|
class="module-card"
|
||||||
|
:class="{ 'is-locked': module.locked }"
|
||||||
@click="handleNavigate(module)"
|
@click="handleNavigate(module)"
|
||||||
>
|
>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="card-title">{{ module.name }}</span>
|
<span class="card-title">
|
||||||
|
{{ module.name }}
|
||||||
|
<el-icon v-if="module.locked" class="lock-badge"><Lock /></el-icon>
|
||||||
|
</span>
|
||||||
<div class="card-icon-wrapper" v-html="module.icon"></div>
|
<div class="card-icon-wrapper" v-html="module.icon"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-divider"></div>
|
<div class="card-divider"></div>
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<p class="card-desc">{{ module.description || "暂无描述" }}</p>
|
<p class="card-desc">{{ module.description || "暂无描述" }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="module.locked" class="card-lock-tip">
|
||||||
|
<span class="lock-text">套餐未开通</span>
|
||||||
|
<span class="buy-btn" @click.stop="handleBuy(module)">购买</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,16 +101,24 @@
|
|||||||
v-for="module in systemModules"
|
v-for="module in systemModules"
|
||||||
:key="module.path"
|
:key="module.path"
|
||||||
class="module-card"
|
class="module-card"
|
||||||
|
:class="{ 'is-locked': module.locked }"
|
||||||
@click="handleNavigate(module)"
|
@click="handleNavigate(module)"
|
||||||
>
|
>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="card-title">{{ module.name }}</span>
|
<span class="card-title">
|
||||||
|
{{ module.name }}
|
||||||
|
<el-icon v-if="module.locked" class="lock-badge"><Lock /></el-icon>
|
||||||
|
</span>
|
||||||
<div class="card-icon-wrapper" v-html="module.icon"></div>
|
<div class="card-icon-wrapper" v-html="module.icon"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-divider"></div>
|
<div class="card-divider"></div>
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<p class="card-desc">{{ module.description || "暂无描述" }}</p>
|
<p class="card-desc">{{ module.description || "暂无描述" }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="module.locked" class="card-lock-tip">
|
||||||
|
<span class="lock-text">套餐未开通</span>
|
||||||
|
<span class="buy-btn" @click.stop="handleBuy(module)">购买</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,16 +131,24 @@
|
|||||||
v-for="module in uncategorizedModules"
|
v-for="module in uncategorizedModules"
|
||||||
:key="module.path"
|
:key="module.path"
|
||||||
class="module-card"
|
class="module-card"
|
||||||
|
:class="{ 'is-locked': module.locked }"
|
||||||
@click="handleNavigate(module)"
|
@click="handleNavigate(module)"
|
||||||
>
|
>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="card-title">{{ module.name }}</span>
|
<span class="card-title">
|
||||||
|
{{ module.name }}
|
||||||
|
<el-icon v-if="module.locked" class="lock-badge"><Lock /></el-icon>
|
||||||
|
</span>
|
||||||
<div class="card-icon-wrapper" v-html="module.icon"></div>
|
<div class="card-icon-wrapper" v-html="module.icon"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-divider"></div>
|
<div class="card-divider"></div>
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<p class="card-desc">{{ module.description || "暂无描述" }}</p>
|
<p class="card-desc">{{ module.description || "暂无描述" }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="module.locked" class="card-lock-tip">
|
||||||
|
<span class="lock-text">套餐未开通</span>
|
||||||
|
<span class="buy-btn" @click.stop="handleBuy(module)">购买</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,7 +170,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, onMounted } from "vue";
|
import { ref, reactive, computed, onMounted } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
@@ -157,6 +181,7 @@ import {
|
|||||||
Sunny,
|
Sunny,
|
||||||
Moon,
|
Moon,
|
||||||
OfficeBuilding,
|
OfficeBuilding,
|
||||||
|
Lock,
|
||||||
} from "@element-plus/icons-vue";
|
} from "@element-plus/icons-vue";
|
||||||
|
|
||||||
import { getTenantList } from "@/api/modules";
|
import { getTenantList } from "@/api/modules";
|
||||||
@@ -175,21 +200,30 @@ interface ModuleItem {
|
|||||||
status: number;
|
status: number;
|
||||||
type: number;
|
type: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
locked?: boolean;
|
||||||
|
is_show?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const menuStore = useMenuStore();
|
const menuStore = useMenuStore();
|
||||||
|
|
||||||
|
// 已开通模块排在前面,套餐未开通(锁定)的模块沉到后方
|
||||||
|
function sortUnlockedFirst(list: ModuleItem[]): ModuleItem[] {
|
||||||
|
return [...list].sort(
|
||||||
|
(a, b) => Number(!!a.locked) - Number(!!b.locked)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 根据type分组模块
|
// 根据type分组模块
|
||||||
const basicModules = computed(() =>
|
const basicModules = computed(() =>
|
||||||
moduleList.value.filter((item) => item.type === 1)
|
sortUnlockedFirst(moduleList.value.filter((item) => item.type === 1))
|
||||||
);
|
);
|
||||||
const systemModules = computed(() =>
|
const systemModules = computed(() =>
|
||||||
moduleList.value.filter((item) => item.type === 2)
|
sortUnlockedFirst(moduleList.value.filter((item) => item.type === 2))
|
||||||
);
|
);
|
||||||
const uncategorizedModules = computed(() =>
|
const uncategorizedModules = computed(() =>
|
||||||
moduleList.value.filter((item) => item.type === 0)
|
sortUnlockedFirst(moduleList.value.filter((item) => item.type === 0))
|
||||||
);
|
);
|
||||||
|
|
||||||
const moduleList = ref<ModuleItem[]>([]);
|
const moduleList = ref<ModuleItem[]>([]);
|
||||||
@@ -265,8 +299,32 @@ const fetchUserProfile = async () => {
|
|||||||
// 当前租户(企业)名称,来自后端 /backend/getCurrentUser 或登录接口返回的 tenant_name
|
// 当前租户(企业)名称,来自后端 /backend/getCurrentUser 或登录接口返回的 tenant_name
|
||||||
const tenantName = computed(() => authStore.user?.tenant_name || "");
|
const tenantName = computed(() => authStore.user?.tenant_name || "");
|
||||||
|
|
||||||
|
// 当前租户生效套餐名称(用于未开通提示)
|
||||||
|
const packageName = ref("");
|
||||||
|
// 套餐购买链接(平台端配置,为空时点击「购买」仅提示联系管理员)
|
||||||
|
const purchaseUrl = ref("");
|
||||||
|
|
||||||
|
// 套餐未开通模块:不跳转,提示购买套餐后使用
|
||||||
|
function showLockedTip(module: ModuleItem) {
|
||||||
|
const pkg = packageName.value ? `「${packageName.value}」` : "当前";
|
||||||
|
ElMessageBox.alert(
|
||||||
|
`${pkg}套餐未包含「${module.name}」功能模块,请购买或升级套餐后使用。如有疑问请联系平台管理员。`,
|
||||||
|
"功能未开通",
|
||||||
|
{
|
||||||
|
confirmButtonText: "我知道了",
|
||||||
|
type: "warning",
|
||||||
|
customClass: "module-locked-tip"
|
||||||
|
}
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
// 处理导航跳转
|
// 处理导航跳转
|
||||||
function handleNavigate(module: ModuleItem) {
|
function handleNavigate(module: ModuleItem) {
|
||||||
|
if (module.locked) {
|
||||||
|
showLockedTip(module);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const isWebsiteManagement = module.name === "网站管理" || module.path.startsWith("/apps/cms");
|
const isWebsiteManagement = module.name === "网站管理" || module.path.startsWith("/apps/cms");
|
||||||
const targetPath = isWebsiteManagement
|
const targetPath = isWebsiteManagement
|
||||||
? "/apps/cms/analytics/content"
|
? "/apps/cms/analytics/content"
|
||||||
@@ -275,6 +333,22 @@ function handleNavigate(module: ModuleItem) {
|
|||||||
if (targetPath) router.push(targetPath);
|
if (targetPath) router.push(targetPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 点击「购买」:跳转该产品(按功能模块编码)的购买页,展示产品介绍 / 功能节点 / 价格档位
|
||||||
|
function handleBuy(module: ModuleItem) {
|
||||||
|
const code = (module.code || "").trim();
|
||||||
|
if (code) {
|
||||||
|
router.push(`/product/${code}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 无模块编码时回退到平台配置的外部购买链接,仍无则提示联系管理员
|
||||||
|
const url = (purchaseUrl.value || "").trim();
|
||||||
|
if (url) {
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showLockedTip(module);
|
||||||
|
}
|
||||||
|
|
||||||
// 刷新菜单
|
// 刷新菜单
|
||||||
async function handleRefreshMenus() {
|
async function handleRefreshMenus() {
|
||||||
refreshLoading.value = true;
|
refreshLoading.value = true;
|
||||||
@@ -331,10 +405,13 @@ async function loadModules() {
|
|||||||
const mapped = {
|
const mapped = {
|
||||||
...item,
|
...item,
|
||||||
type: item.type ?? 0, // 默认为0(未分类)
|
type: item.type ?? 0, // 默认为0(未分类)
|
||||||
|
locked: !!item.locked, // 套餐未开通模块:置灰并锁定
|
||||||
title: item.name // 添加title字段用于显示分类标题
|
title: item.name // 添加title字段用于显示分类标题
|
||||||
};
|
};
|
||||||
return mapped;
|
return mapped;
|
||||||
});
|
});
|
||||||
|
packageName.value = res.data?.package_name || "";
|
||||||
|
purchaseUrl.value = res.data?.purchase_url || "";
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("加载模块列表失败:", error);
|
console.error("加载模块列表失败:", error);
|
||||||
@@ -580,7 +657,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.module-card {
|
.module-card {
|
||||||
height: 180px;
|
min-height: 180px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 30px;
|
padding: 30px;
|
||||||
@@ -606,9 +683,17 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: @text-main;
|
color: @text-main;
|
||||||
|
|
||||||
|
.lock-badge {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #c0c4cc;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -631,6 +716,79 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-lock-tip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #c0c4cc;
|
||||||
|
|
||||||
|
.buy-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border: 1px solid #3973ff;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #3973ff;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #3973ff;
|
||||||
|
border-color: #3973ff;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 套餐未开通模块:置灰 + 禁止点击效果
|
||||||
|
&.is-locked {
|
||||||
|
background: #fafafa;
|
||||||
|
border-style: dashed;
|
||||||
|
border-color: #dcdfe6;
|
||||||
|
box-shadow: none;
|
||||||
|
cursor: not-allowed;
|
||||||
|
filter: grayscale(1);
|
||||||
|
opacity: 0.8;
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-desc {
|
||||||
|
color: #a8abb2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icon-wrapper {
|
||||||
|
color: #c0c4cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-divider {
|
||||||
|
border-color: #e4e7ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: none;
|
||||||
|
background: #fafafa;
|
||||||
|
box-shadow: none;
|
||||||
|
|
||||||
|
.card-title,
|
||||||
|
.card-desc {
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icon-wrapper {
|
||||||
|
color: #c0c4cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-divider {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.15);
|
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.15);
|
||||||
@@ -947,6 +1105,43 @@ onMounted(() => {
|
|||||||
.card-divider {
|
.card-divider {
|
||||||
border-color: #3d3d3d;
|
border-color: #3d3d3d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.is-locked {
|
||||||
|
background: #1f1f1f;
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
color: #8c8c8c !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-desc {
|
||||||
|
color: #6b6b6b !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icon-wrapper {
|
||||||
|
color: #5a5a5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-badge {
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-lock-tip {
|
||||||
|
color: #6b6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #1f1f1f;
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
color: #8c8c8c !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-desc {
|
||||||
|
color: #6b6b6b !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -972,6 +1167,19 @@ onMounted(() => {
|
|||||||
.modules-section .module-card .card-desc {
|
.modules-section .module-card .card-desc {
|
||||||
color: #8c8c8c !important;
|
color: #8c8c8c !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modules-section .module-card.is-locked {
|
||||||
|
background: #1f1f1f;
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
color: #8c8c8c !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-desc {
|
||||||
|
color: #6b6b6b !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<div class="locked-container">
|
||||||
|
<div class="locked-content">
|
||||||
|
<div class="locked-icon">
|
||||||
|
<i class="fa-solid fa-lock"></i>
|
||||||
|
</div>
|
||||||
|
<div class="locked-message">功能未开通</div>
|
||||||
|
<div class="locked-desc">
|
||||||
|
当前套餐未包含<template v-if="moduleName">「{{ moduleName }}」</template>功能模块,请联系管理员升级套餐后使用。
|
||||||
|
</div>
|
||||||
|
<div class="locked-actions">
|
||||||
|
<a href="javascript:void(0)" class="back-home-btn" @click.prevent="backToHome">返回首页</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const moduleName = computed(() => route.meta?.title || "");
|
||||||
|
|
||||||
|
const backToHome = () => {
|
||||||
|
router.push("/home");
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.locked-container {
|
||||||
|
min-height: 100%;
|
||||||
|
background: linear-gradient(120deg, #e0e7ff 0%, #fce7f3 100%);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locked-content {
|
||||||
|
background: #fff;
|
||||||
|
padding: 48px 48px 40px;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 460px;
|
||||||
|
animation: float-in 0.6s cubic-bezier(0.7, 0.13, 0.45, 0.81) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locked-icon {
|
||||||
|
width: 88px;
|
||||||
|
height: 88px;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #eef2ff 0%, #fdf2f8 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 36px;
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locked-message {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #222;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locked-desc {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locked-actions {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-home-btn {
|
||||||
|
display: inline-block;
|
||||||
|
background: linear-gradient(90deg, #3973ff 0%, #6a82fb 100%);
|
||||||
|
color: #fff;
|
||||||
|
padding: 10px 36px;
|
||||||
|
border-radius: 24px;
|
||||||
|
font-size: 15px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-home-btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float-in {
|
||||||
|
0% {
|
||||||
|
transform: translateY(30px) scale(0.97);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,585 @@
|
|||||||
|
<template>
|
||||||
|
<div class="product-page">
|
||||||
|
<!-- 顶部栏 -->
|
||||||
|
<div class="page-top">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<el-button link class="back-btn" @click="goHome">
|
||||||
|
<el-icon><ArrowLeft /></el-icon>
|
||||||
|
返回
|
||||||
|
</el-button>
|
||||||
|
<span v-if="tenantName" class="tenant-name">
|
||||||
|
<el-icon><OfficeBuilding /></el-icon>
|
||||||
|
{{ tenantName }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<el-button link @click="ordersVisible = true">我的订单</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-body">
|
||||||
|
<el-skeleton v-if="loading" :rows="8" animated />
|
||||||
|
|
||||||
|
<div v-else-if="!product" class="empty-state">
|
||||||
|
<el-icon :size="64"><Box /></el-icon>
|
||||||
|
<p class="empty-text">该产品暂未发布定价</p>
|
||||||
|
<p class="empty-tip">请联系平台管理员配置产品与价格后购买</p>
|
||||||
|
<el-button type="primary" @click="goHome">返回首页</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- 产品头部 -->
|
||||||
|
<section class="hero">
|
||||||
|
<div class="hero-main">
|
||||||
|
<h1 class="hero-title">{{ product.name }}</h1>
|
||||||
|
<p class="hero-code">产品编码:{{ product.code }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-price">
|
||||||
|
<span class="price-symbol">¥</span>
|
||||||
|
<span class="price-value">{{ Number(product.price || 0).toFixed(2) }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 功能节点树 -->
|
||||||
|
<section v-if="featureTree.length > 0" class="section">
|
||||||
|
<h2 class="section-title">功能清单</h2>
|
||||||
|
<div class="feature-box">
|
||||||
|
<el-tree
|
||||||
|
:data="featureTree"
|
||||||
|
:props="{ label: 'title', children: 'children' }"
|
||||||
|
node-key="key"
|
||||||
|
default-expand-all
|
||||||
|
>
|
||||||
|
<template #default="{ data }">
|
||||||
|
<div class="tree-node">
|
||||||
|
<span class="node-title">{{ data.title }}</span>
|
||||||
|
<span v-if="data.description" class="node-desc">{{ data.description }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-tree>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 结算区 -->
|
||||||
|
<section class="section">
|
||||||
|
<div class="checkout">
|
||||||
|
<div class="checkout-info">
|
||||||
|
<span class="checkout-label">应付金额:</span>
|
||||||
|
<span class="checkout-amount">¥{{ Number(product.price || 0).toFixed(2) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="channels.length > 0" class="checkout-pay">
|
||||||
|
<span class="checkout-label">支付方式:</span>
|
||||||
|
<el-radio-group v-model="channel">
|
||||||
|
<el-radio v-for="c in channels" :key="c.channel" :value="c.channel">
|
||||||
|
{{ c.name || c.channel }}
|
||||||
|
</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" size="large" :loading="submitting" @click="handleBuy">
|
||||||
|
立即购买
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<p v-if="channels.length === 0" class="checkout-tip">
|
||||||
|
平台暂未配置在线支付渠道,点击「立即购买」将提交购买申请,由平台确认后开通。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 支付弹窗 -->
|
||||||
|
<el-dialog v-model="payVisible" title="扫码支付" width="420px" :close-on-click-modal="false">
|
||||||
|
<div v-if="payParams?.code_url" class="pay-body">
|
||||||
|
<canvas ref="qrRef" class="pay-qr"></canvas>
|
||||||
|
<p class="pay-tip">请使用{{ currentChannelName }}扫码支付</p>
|
||||||
|
<p class="pay-amount">¥{{ Number(product?.price || 0).toFixed(2) }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="payParams?.redirect_url" class="pay-body">
|
||||||
|
<p class="pay-tip">正在跳转{{ currentChannelName }}收银台…</p>
|
||||||
|
<el-button type="primary" @click="openPayUrl(payParams.redirect_url)">重新打开支付页</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="pay-body">
|
||||||
|
<el-icon :size="48" color="#67c23a"><CircleCheckFilled /></el-icon>
|
||||||
|
<p class="pay-tip">购买申请已提交,请等待平台确认开通</p>
|
||||||
|
</div>
|
||||||
|
<p class="pay-status">{{ payStatusText }}</p>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="handleCancelPay">取消支付</el-button>
|
||||||
|
<el-button type="primary" :loading="checking" @click="checkOrder">我已完成支付</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 我的订单 -->
|
||||||
|
<el-dialog v-model="ordersVisible" title="我的订单" width="720px">
|
||||||
|
<el-table :data="myOrders" size="small" v-loading="ordersLoading">
|
||||||
|
<el-table-column prop="order_no" label="订单号" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="product_name" label="产品" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column label="金额" width="100" align="center">
|
||||||
|
<template #default="{ row }">¥{{ Number(row.amount || 0).toFixed(2) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="orderStatusType(row.status)" size="small">
|
||||||
|
{{ orderStatusText(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="create_time" label="下单时间" width="170" align="center" />
|
||||||
|
</el-table>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch, nextTick } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
import { ArrowLeft, OfficeBuilding, Box, CircleCheckFilled } from "@element-plus/icons-vue";
|
||||||
|
import QRCode from "qrcode";
|
||||||
|
import {
|
||||||
|
getProductDetailByCode,
|
||||||
|
createProductOrder,
|
||||||
|
getProductOrderStatus,
|
||||||
|
getMyProductOrders,
|
||||||
|
getPaymentChannels,
|
||||||
|
} from "@/api/product";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
|
interface FeatureNode {
|
||||||
|
key: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
children?: FeatureNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const product = ref<any>(null);
|
||||||
|
const channels = ref<any[]>([]);
|
||||||
|
const channel = ref("");
|
||||||
|
const submitting = ref(false);
|
||||||
|
|
||||||
|
const payVisible = ref(false);
|
||||||
|
const payParams = ref<any>(null);
|
||||||
|
const orderNo = ref("");
|
||||||
|
const checking = ref(false);
|
||||||
|
const payStatusText = ref("");
|
||||||
|
const qrRef = ref<HTMLCanvasElement | null>(null);
|
||||||
|
let pollTimer: any = null;
|
||||||
|
|
||||||
|
const ordersVisible = ref(false);
|
||||||
|
const myOrders = ref<any[]>([]);
|
||||||
|
const ordersLoading = ref(false);
|
||||||
|
|
||||||
|
const tenantName = computed(() => authStore.user?.tenant_name || "");
|
||||||
|
|
||||||
|
/** 扁平功能节点 -> 树 */
|
||||||
|
const featureTree = computed<FeatureNode[]>(() => {
|
||||||
|
const list: any[] = product.value?.features || [];
|
||||||
|
const map = new Map<number, FeatureNode>();
|
||||||
|
const roots: FeatureNode[] = [];
|
||||||
|
list.forEach((f) =>
|
||||||
|
map.set(Number(f.id), {
|
||||||
|
key: Number(f.id),
|
||||||
|
title: f.title || "",
|
||||||
|
description: f.description || "",
|
||||||
|
children: [],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
list.forEach((f) => {
|
||||||
|
const node = map.get(Number(f.id))!;
|
||||||
|
const parent = f.pid ? map.get(Number(f.pid)) : undefined;
|
||||||
|
if (parent) parent.children!.push(node);
|
||||||
|
else roots.push(node);
|
||||||
|
});
|
||||||
|
return roots;
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentChannelName = computed(() => {
|
||||||
|
const c = channels.value.find((x) => x.channel === channel.value);
|
||||||
|
return c?.name || channel.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const orderStatusText = (s: string) =>
|
||||||
|
({ pending: "待支付", paid: "已支付", activated: "已开通", closed: "已关闭" }[s] || s);
|
||||||
|
const orderStatusType = (s: string) =>
|
||||||
|
({ pending: "warning", paid: "primary", activated: "success", closed: "info" }[s] || "info");
|
||||||
|
|
||||||
|
const goHome = () => router.push("/home");
|
||||||
|
|
||||||
|
const loadProduct = async () => {
|
||||||
|
const code = String(route.params.code || "").trim();
|
||||||
|
if (!code) {
|
||||||
|
product.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getProductDetailByCode(code);
|
||||||
|
product.value = res.code === 200 && res.data ? res.data : null;
|
||||||
|
} catch {
|
||||||
|
product.value = null;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadChannels = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getPaymentChannels();
|
||||||
|
if (res.code === 200) {
|
||||||
|
channels.value = res.data?.list || [];
|
||||||
|
if (channels.value.length > 0) channel.value = channels.value[0].channel;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
channels.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPayUrl = (url: string) => {
|
||||||
|
if (url) window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderQR = async () => {
|
||||||
|
if (!payParams.value?.code_url) return;
|
||||||
|
await nextTick();
|
||||||
|
if (!qrRef.value) return;
|
||||||
|
try {
|
||||||
|
await QRCode.toCanvas(qrRef.value, payParams.value.code_url, { width: 200, margin: 1 });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("二维码生成失败:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBuy = async () => {
|
||||||
|
if (!product.value) return;
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
const res = await createProductOrder({
|
||||||
|
product_code: product.value.code,
|
||||||
|
channel: channels.value.length > 0 ? channel.value : "",
|
||||||
|
pay_type: "",
|
||||||
|
});
|
||||||
|
if (res.code !== 200) {
|
||||||
|
ElMessage.error(res.msg || "下单失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
orderNo.value = res.data?.order_no || "";
|
||||||
|
payParams.value = res.data?.pay_params || null;
|
||||||
|
payStatusText.value = res.data?.offline
|
||||||
|
? "购买申请已提交,请等待平台确认开通"
|
||||||
|
: "请在有效期内完成支付,支付成功后由平台开通功能";
|
||||||
|
payVisible.value = true;
|
||||||
|
await renderQR();
|
||||||
|
if (payParams.value?.redirect_url) openPayUrl(payParams.value.redirect_url);
|
||||||
|
if (!res.data?.offline) startPolling();
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || "下单失败");
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startPolling = () => {
|
||||||
|
stopPolling();
|
||||||
|
pollTimer = setInterval(() => checkOrder(true), 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopPolling = () => {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkOrder = async (silent = false) => {
|
||||||
|
if (!orderNo.value) return;
|
||||||
|
checking.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getProductOrderStatus(orderNo.value);
|
||||||
|
if (res.code === 200 && res.data) {
|
||||||
|
const status = res.data.status;
|
||||||
|
if (status === "activated") {
|
||||||
|
stopPolling();
|
||||||
|
ElMessage.success("购买成功,功能已开通");
|
||||||
|
payVisible.value = false;
|
||||||
|
loadMyOrders();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status === "paid") {
|
||||||
|
payStatusText.value = "已支付,等待平台开通…";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
payStatusText.value = silent
|
||||||
|
? payStatusText.value || "等待支付…"
|
||||||
|
: "尚未检测到支付成功,请确认已完成支付";
|
||||||
|
if (!silent) ElMessage.info("尚未检测到支付成功");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!silent) ElMessage.error("查询订单失败");
|
||||||
|
} finally {
|
||||||
|
checking.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelPay = () => {
|
||||||
|
stopPolling();
|
||||||
|
payVisible.value = false;
|
||||||
|
ElMessage.info("已取消,订单仍可在「我的订单」中查看");
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMyOrders = async () => {
|
||||||
|
ordersLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getMyProductOrders();
|
||||||
|
if (res.code === 200) myOrders.value = res.data?.list || [];
|
||||||
|
} catch {
|
||||||
|
myOrders.value = [];
|
||||||
|
} finally {
|
||||||
|
ordersLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(ordersVisible, (v) => {
|
||||||
|
if (v) loadMyOrders();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(payVisible, (v) => {
|
||||||
|
if (!v) stopPolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadProduct();
|
||||||
|
loadChannels();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.product-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f7fa;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-top {
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 10px 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.toolbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.tenant-name {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-body {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 28px;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-code {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-price {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
.price-symbol {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-value {
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-top: 28px;
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding-left: 8px;
|
||||||
|
border-left: 4px solid #667eea;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-box {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
padding: 16px 18px;
|
||||||
|
|
||||||
|
.tree-node {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.node-title {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkout {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 18px 22px;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
|
||||||
|
.checkout-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.checkout-amount {
|
||||||
|
color: #f56c6c;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkout-pay {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkout-label {
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkout-tip {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-body {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.pay-qr {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-tip {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #606266;
|
||||||
|
margin: 12px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-amount {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f56c6c;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-status {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #909399;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 80px 20px;
|
||||||
|
color: #c0c4cc;
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-tip {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #909399;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page-body {
|
||||||
|
padding: 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkout {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -113,10 +113,12 @@ func (c *BackendMenuController) GetBackendMenu() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 套餐功能开通过滤:仅展示租户套餐已包含的功能模块对应的菜单(未包含的整块菜单不展示)
|
// 套餐功能开通标记:未开通模块的菜单保留但标记 locked(前端展示锁定态与升级提示,不再 404)
|
||||||
backendMenus = filterMenusByTenantPackage(c.Ctx.Request.Header.Get("Authorization"), backendMenus, tid)
|
backendMenus, lockedMenuIDs := filterMenusByTenantPackageLocked(c.Ctx.Request.Header.Get("Authorization"), backendMenus, tid, true)
|
||||||
|
menuTree := buildMenuTree(backendMenus, 0)
|
||||||
|
markTreeLocked(menuTree, lockedMenuIDs)
|
||||||
|
|
||||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": buildMenuTree(backendMenus, 0)}
|
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": menuTree}
|
||||||
_ = c.ServeJSON()
|
_ = c.ServeJSON()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,26 +141,51 @@ func backendJWTTenantID(auth string) uint64 {
|
|||||||
return uint64(claims.TenantId)
|
return uint64(claims.TenantId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// filterMenusByTenantPackage 按租户套餐包含的功能模块过滤菜单。
|
// filterMenusByTenantPackage 按租户套餐包含的功能模块过滤菜单(剔除未开通模块,用于角色菜单分配等场景)。
|
||||||
// tid 优先取 token 中的租户ID,其次用调用方传入的兜底值;两者都取不到时不处理(保持原行为)。
|
// tid 优先取 token 中的租户ID,其次用调用方传入的兜底值;两者都取不到时不处理(保持原行为)。
|
||||||
func filterMenusByTenantPackage(authHeader string, menus []models.SystemMenu, tid uint64) []models.SystemMenu {
|
func filterMenusByTenantPackage(authHeader string, menus []models.SystemMenu, tid uint64) []models.SystemMenu {
|
||||||
|
filtered, _ := filterMenusByTenantPackageLocked(authHeader, menus, tid, false)
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterMenusByTenantPackageLocked 按租户套餐处理菜单。
|
||||||
|
// markLocked=false:剔除未开通模块的菜单(原行为);markLocked=true:保留菜单并返回锁定菜单ID集合(含后代)。
|
||||||
|
func filterMenusByTenantPackageLocked(authHeader string, menus []models.SystemMenu, tid uint64, markLocked bool) ([]models.SystemMenu, map[uint64]bool) {
|
||||||
if jwtTid := backendJWTTenantID(authHeader); jwtTid > 0 {
|
if jwtTid := backendJWTTenantID(authHeader); jwtTid > 0 {
|
||||||
tid = jwtTid
|
tid = jwtTid
|
||||||
}
|
}
|
||||||
if tid == 0 {
|
if tid == 0 {
|
||||||
return menus
|
return menus, nil
|
||||||
}
|
}
|
||||||
allModules, err := services.ListEnabledModules()
|
allModules, err := services.ListEnabledModules()
|
||||||
if err != nil || len(allModules) == 0 {
|
if err != nil || len(allModules) == 0 {
|
||||||
return menus
|
return menus, nil
|
||||||
}
|
}
|
||||||
allowed := services.GetTenantModuleCodes(tid)
|
allowed := services.GetTenantModuleCodes(tid)
|
||||||
// 安全兜底:租户没有绑定套餐或套餐未配置任何功能模块时不做过滤,
|
// 安全兜底:租户没有绑定套餐或套餐未配置任何功能模块时不做过滤,
|
||||||
// 避免因套餐数据缺失导致租户端菜单整体消失。
|
// 避免因套餐数据缺失导致租户端菜单整体消失。
|
||||||
if len(allowed) == 0 {
|
if len(allowed) == 0 {
|
||||||
return menus
|
return menus, nil
|
||||||
|
}
|
||||||
|
if markLocked {
|
||||||
|
return services.MarkMenusLockedByTenantModules(menus, allModules, allowed)
|
||||||
|
}
|
||||||
|
return services.FilterMenusByTenantModules(menus, allModules, allowed), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// markTreeLocked 将菜单树中位于锁定集合的节点标记为 locked
|
||||||
|
func markTreeLocked(nodes []*menuNode, locked map[uint64]bool) {
|
||||||
|
if len(nodes) == 0 || len(locked) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, n := range nodes {
|
||||||
|
if locked[n.ID] {
|
||||||
|
n.Locked = true
|
||||||
|
}
|
||||||
|
if len(n.Children) > 0 {
|
||||||
|
markTreeLocked(n.Children, locked)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return services.FilterMenusByTenantModules(menus, allModules, allowed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseRightsToSet 将角色 rights 解析为菜单 ID 集合(兼容 JSON 数组 / 逗号分隔)
|
// parseRightsToSet 将角色 rights 解析为菜单 ID 集合(兼容 JSON 数组 / 逗号分隔)
|
||||||
@@ -350,6 +377,7 @@ type menuNode struct {
|
|||||||
Views []int `json:"views,omitempty"`
|
Views []int `json:"views,omitempty"`
|
||||||
Type int8 `json:"type"`
|
Type int8 `json:"type"`
|
||||||
Permission string `json:"permission,omitempty"`
|
Permission string `json:"permission,omitempty"`
|
||||||
|
Locked bool `json:"locked,omitempty"`
|
||||||
Children []*menuNode `json:"children,omitempty"`
|
Children []*menuNode `json:"children,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,18 @@ import (
|
|||||||
|
|
||||||
"server/models"
|
"server/models"
|
||||||
"server/pkg/jwtutil"
|
"server/pkg/jwtutil"
|
||||||
|
"server/services"
|
||||||
|
|
||||||
beego "github.com/beego/beego/v2/server/web"
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// tenantModuleItem 模块列表返回项:在模块基础信息上补充套餐开通状态,
|
||||||
|
// locked=true 表示该模块不在当前租户已购套餐内(前端置灰并提示购买套餐)。
|
||||||
|
type tenantModuleItem struct {
|
||||||
|
models.SystemModules
|
||||||
|
Locked bool `json:"locked"`
|
||||||
|
}
|
||||||
|
|
||||||
// BackendModulesController backend 模块接口(yz_system_modules)
|
// BackendModulesController backend 模块接口(yz_system_modules)
|
||||||
type BackendModulesController struct {
|
type BackendModulesController struct {
|
||||||
beego.Controller
|
beego.Controller
|
||||||
@@ -41,14 +49,16 @@ func (c *BackendModulesController) jsonErr(httpStatus, bizCode int, msg string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetTenantList GET /backend/modules/getTenantList
|
// GetTenantList GET /backend/modules/getTenantList
|
||||||
// 返回当前 backend 账号可见的模块。当前实现:返回 status=1 且 is_show=1 的全部模块。
|
// 返回当前 backend 账号可见的模块(status=1 且 is_show=1)。
|
||||||
|
// 同时按当前租户已购套餐标记 locked:未开通的功能模块前端置灰、禁止跳转并提示购买套餐。
|
||||||
func (c *BackendModulesController) GetTenantList() {
|
func (c *BackendModulesController) GetTenantList() {
|
||||||
if _, err := c.backendModulesClaims(); err != nil {
|
claims, err := c.backendModulesClaims()
|
||||||
|
if err != nil {
|
||||||
c.jsonErr(401, 401, err.Error())
|
c.jsonErr(401, 401, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var rows []models.SystemModules
|
var rows []models.SystemModules
|
||||||
_, err := models.Orm.QueryTable(new(models.SystemModules)).
|
_, err = models.Orm.QueryTable(new(models.SystemModules)).
|
||||||
Filter("delete_time__isnull", true).
|
Filter("delete_time__isnull", true).
|
||||||
Filter("status", 1).
|
Filter("status", 1).
|
||||||
Filter("is_show", 1).
|
Filter("is_show", 1).
|
||||||
@@ -58,12 +68,33 @@ func (c *BackendModulesController) GetTenantList() {
|
|||||||
c.jsonErr(500, 500, "获取失败:"+err.Error())
|
c.jsonErr(500, 500, "获取失败:"+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 租户已开通的模块编码集合(来自租户绑定/生效套餐)
|
||||||
|
allowed := map[string]bool{}
|
||||||
|
packageName := ""
|
||||||
|
if tid := uint64(claims.TenantId); tid > 0 {
|
||||||
|
allowed = services.GetTenantModuleCodes(tid)
|
||||||
|
if pkg, e := services.GetTenantPackage(tid); e == nil && pkg != nil {
|
||||||
|
packageName = pkg.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list := make([]tenantModuleItem, 0, len(rows))
|
||||||
|
for _, m := range rows {
|
||||||
|
// 安全兜底:租户未绑定套餐或套餐未配置任何功能模块时不做锁定,
|
||||||
|
// 避免套餐数据缺失导致全部功能不可用。
|
||||||
|
locked := len(allowed) > 0 && !allowed[strings.TrimSpace(m.Code)]
|
||||||
|
list = append(list, tenantModuleItem{SystemModules: m, Locked: locked})
|
||||||
|
}
|
||||||
|
|
||||||
c.Data["json"] = map[string]interface{}{
|
c.Data["json"] = map[string]interface{}{
|
||||||
"code": 200,
|
"code": 200,
|
||||||
"msg": "获取成功",
|
"msg": "获取成功",
|
||||||
"data": map[string]interface{}{
|
"data": map[string]interface{}{
|
||||||
"list": rows,
|
"list": list,
|
||||||
"total": len(rows),
|
"total": len(list),
|
||||||
|
"package_name": packageName,
|
||||||
|
"purchase_url": services.GetPackagePurchaseURL(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
_ = c.ServeJSON()
|
_ = c.ServeJSON()
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"server/models"
|
||||||
|
"server/pkg/jwtutil"
|
||||||
|
"server/services/payment"
|
||||||
|
|
||||||
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BackendProductPurchaseController 租户端「产品购买」接口(只读产品 + 下单 + 订单状态)。
|
||||||
|
// 注意:与 CMS 产品管理的 BackendProductController 区分开。
|
||||||
|
// 租户身份来自 JWT:UserType=backend,TenantId 即租户ID。
|
||||||
|
type BackendProductPurchaseController struct {
|
||||||
|
beego.Controller
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *BackendProductPurchaseController) claims() (*jwtutil.Claims, error) {
|
||||||
|
auth := c.Ctx.Request.Header.Get("Authorization")
|
||||||
|
if auth == "" {
|
||||||
|
return nil, errors.New("未登录")
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(auth, " ", 2)
|
||||||
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||||
|
return nil, errors.New("认证信息格式错误")
|
||||||
|
}
|
||||||
|
claims, err := jwtutil.ParseToken(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("无效的token")
|
||||||
|
}
|
||||||
|
if claims.UserType != "backend" {
|
||||||
|
return nil, errors.New("无权访问")
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *BackendProductPurchaseController) jsonErr(httpStatus, bizCode int, msg string) {
|
||||||
|
c.Ctx.Output.SetStatus(httpStatus)
|
||||||
|
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *BackendProductPurchaseController) jsonOK(data interface{}, msg string) {
|
||||||
|
if msg == "" {
|
||||||
|
msg = "success"
|
||||||
|
}
|
||||||
|
resp := map[string]interface{}{"code": 200, "msg": msg}
|
||||||
|
if data != nil {
|
||||||
|
resp["data"] = data
|
||||||
|
}
|
||||||
|
c.Data["json"] = resp
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *BackendProductPurchaseController) tenantName(tid uint64) string {
|
||||||
|
if tid == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
tenant := &models.SystemTenant{}
|
||||||
|
if err := models.Orm.QueryTable(new(models.SystemTenant)).
|
||||||
|
Filter("id", tid).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
One(tenant); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return tenant.TenantName
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProductList GET /backend/product/list
|
||||||
|
// 上架产品列表(含功能节点与启用档位),供购买页/产品列表展示
|
||||||
|
func (c *BackendProductPurchaseController) GetProductList() {
|
||||||
|
if _, err := c.claims(); err != nil {
|
||||||
|
c.jsonErr(401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var rows []models.PlatformProduct
|
||||||
|
if _, err := models.Orm.QueryTable(new(models.PlatformProduct)).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Filter("status", 1).
|
||||||
|
OrderBy("sort", "id").
|
||||||
|
All(&rows); err != nil {
|
||||||
|
c.jsonErr(500, 500, "获取失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := make([]uint64, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
ids = append(ids, r.ID)
|
||||||
|
}
|
||||||
|
featureMap := loadProductFeatures(ids)
|
||||||
|
|
||||||
|
list := make([]map[string]interface{}, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
list = append(list, productToMap(r, featureMap[r.ID]))
|
||||||
|
}
|
||||||
|
c.jsonOK(map[string]interface{}{"list": list, "total": len(list)}, "获取成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProductDetailByCode GET /backend/product/detail/:code
|
||||||
|
// 按产品编码取详情(租户端 home 卡片按模块 code 跳转购买页时使用)
|
||||||
|
func (c *BackendProductPurchaseController) GetProductDetailByCode() {
|
||||||
|
if _, err := c.claims(); err != nil {
|
||||||
|
c.jsonErr(401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code := strings.TrimSpace(c.Ctx.Input.Param(":code"))
|
||||||
|
if code == "" {
|
||||||
|
c.jsonErr(400, 400, "产品编码不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var row models.PlatformProduct
|
||||||
|
if err := models.Orm.QueryTable(new(models.PlatformProduct)).
|
||||||
|
Filter("code", code).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Filter("status", 1).
|
||||||
|
One(&row); err != nil {
|
||||||
|
c.jsonErr(404, 404, "产品不存在或未上架")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.jsonOK(productToMap(row, loadProductFeatures([]uint64{row.ID})[row.ID]), "获取成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
type createProductOrderPayload struct {
|
||||||
|
ProductCode string `json:"product_code"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
PayType string `json:"pay_type"`
|
||||||
|
ReturnURL string `json:"return_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrder POST /backend/product/order/create
|
||||||
|
// 创建购买订单;channel 非空时同时创建支付单并返回拉起支付参数。
|
||||||
|
func (c *BackendProductPurchaseController) CreateOrder() {
|
||||||
|
claims, err := c.claims()
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tid := uint64(claims.TenantId)
|
||||||
|
if tid == 0 {
|
||||||
|
c.jsonErr(401, 401, "未获取到租户信息")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||||
|
var p createProductOrderPayload
|
||||||
|
if err := json.Unmarshal(body, &p); err != nil {
|
||||||
|
c.jsonErr(400, 400, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.ProductCode = strings.TrimSpace(p.ProductCode)
|
||||||
|
p.Channel = strings.TrimSpace(p.Channel)
|
||||||
|
if p.ProductCode == "" {
|
||||||
|
c.jsonErr(400, 400, "产品不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var product models.PlatformProduct
|
||||||
|
if err := models.Orm.QueryTable(new(models.PlatformProduct)).
|
||||||
|
Filter("code", p.ProductCode).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Filter("status", 1).
|
||||||
|
One(&product); err != nil {
|
||||||
|
c.jsonErr(404, 404, "产品不存在或未上架")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if product.Price <= 0 {
|
||||||
|
c.jsonErr(400, 400, "该产品未配置价格,请联系平台")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
order := &models.PlatformProductOrder{
|
||||||
|
OrderNo: "PO" + strconv.FormatInt(now.UnixNano(), 10),
|
||||||
|
Tid: tid,
|
||||||
|
TenantName: c.tenantName(tid),
|
||||||
|
ProductID: product.ID,
|
||||||
|
ProductCode: product.Code,
|
||||||
|
ProductName: product.Name,
|
||||||
|
Amount: product.Price,
|
||||||
|
Status: models.ProductOrderStatusPending,
|
||||||
|
UpdateTime: &now,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未选择支付渠道:仅落单,等待平台确认(线下转账/试用审批场景)
|
||||||
|
if p.Channel == "" {
|
||||||
|
if _, err := models.Orm.Insert(order); err != nil {
|
||||||
|
c.jsonErr(500, 500, "下单失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.jsonOK(map[string]interface{}{
|
||||||
|
"order_no": order.OrderNo,
|
||||||
|
"pay_no": "",
|
||||||
|
"amount": order.Amount,
|
||||||
|
"offline": true,
|
||||||
|
}, "下单成功,请等待平台开通")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
amountFen := int64(order.Amount*100 + 0.5)
|
||||||
|
subject := product.Name
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
payOrder, params, err := payment.CreatePayment(ctx, payment.CreateInput{
|
||||||
|
OutTradeNo: order.OrderNo,
|
||||||
|
OrderType: models.CommissionOrderTypeModuleShop,
|
||||||
|
TenantID: strconv.FormatUint(tid, 10),
|
||||||
|
TenantName: order.TenantName,
|
||||||
|
Amount: amountFen,
|
||||||
|
Channel: p.Channel,
|
||||||
|
Subject: subject,
|
||||||
|
ReturnURL: strings.TrimSpace(p.ReturnURL),
|
||||||
|
ClientIP: c.Ctx.Input.IP(),
|
||||||
|
PayType: p.PayType,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(400, 400, "创建支付单失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
order.PayNo = payOrder.PayNo
|
||||||
|
order.Channel = p.Channel
|
||||||
|
if _, err := models.Orm.Insert(order); err != nil {
|
||||||
|
c.jsonErr(500, 500, "下单失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.jsonOK(map[string]interface{}{
|
||||||
|
"order_no": order.OrderNo,
|
||||||
|
"pay_no": payOrder.PayNo,
|
||||||
|
"amount": order.Amount,
|
||||||
|
"channel": p.Channel,
|
||||||
|
"pay_params": params,
|
||||||
|
"offline": false,
|
||||||
|
}, "下单成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrderStatus GET /backend/product/order/status?order_no=xxx
|
||||||
|
// 查询订单状态;在线支付时同步渠道状态,已支付则自动开通套餐。
|
||||||
|
func (c *BackendProductPurchaseController) GetOrderStatus() {
|
||||||
|
claims, err := c.claims()
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orderNo := strings.TrimSpace(c.GetString("order_no"))
|
||||||
|
if orderNo == "" {
|
||||||
|
c.jsonErr(400, 400, "订单号不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var order models.PlatformProductOrder
|
||||||
|
if err := models.Orm.QueryTable(new(models.PlatformProductOrder)).
|
||||||
|
Filter("order_no", orderNo).
|
||||||
|
Filter("tid", uint64(claims.TenantId)).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
One(&order); err != nil {
|
||||||
|
c.jsonErr(404, 404, "订单不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在线支付:同步渠道状态,支付成功后自动开通
|
||||||
|
if order.Status == models.ProductOrderStatusPending && order.PayNo != "" {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if payOrder, qerr := payment.QueryPayment(ctx, order.PayNo, true); qerr == nil && payOrder.Status == models.PayStatusPaid {
|
||||||
|
now := time.Now()
|
||||||
|
order.Status = models.ProductOrderStatusPaid
|
||||||
|
order.UpdateTime = &now
|
||||||
|
_, _ = models.Orm.Update(&order, "Status", "UpdateTime")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if order.Status == models.ProductOrderStatusPaid {
|
||||||
|
if err := activateProductOrder(&order, "支付成功自动开通"); err == nil {
|
||||||
|
_ = models.Orm.Read(&order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.jsonOK(map[string]interface{}{
|
||||||
|
"order_no": order.OrderNo,
|
||||||
|
"status": order.Status,
|
||||||
|
"amount": order.Amount,
|
||||||
|
"product_name": order.ProductName,
|
||||||
|
"pay_no": order.PayNo,
|
||||||
|
"activate_time": order.ActivateTime,
|
||||||
|
}, "获取成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMyOrders GET /backend/product/order/list 本租户购买记录
|
||||||
|
func (c *BackendProductPurchaseController) GetMyOrders() {
|
||||||
|
claims, err := c.claims()
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var rows []models.PlatformProductOrder
|
||||||
|
if _, err := models.Orm.QueryTable(new(models.PlatformProductOrder)).
|
||||||
|
Filter("tid", uint64(claims.TenantId)).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
OrderBy("-id").
|
||||||
|
Limit(50).
|
||||||
|
All(&rows); err != nil {
|
||||||
|
c.jsonErr(500, 500, "获取失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.jsonOK(map[string]interface{}{"list": rows, "total": len(rows)}, "获取成功")
|
||||||
|
}
|
||||||
@@ -54,6 +54,9 @@ func (c *BackendTenantQuotaController) GetQuotaInfo() {
|
|||||||
"used": info.Used,
|
"used": info.Used,
|
||||||
"remaining": info.Remaining,
|
"remaining": info.Remaining,
|
||||||
"extra_user_price": info.ExtraUserPrice,
|
"extra_user_price": info.ExtraUserPrice,
|
||||||
|
"duration_days": info.DurationDays,
|
||||||
|
"package_expire_time": info.PackageExpireTime,
|
||||||
|
"days_remaining": info.DaysRemaining,
|
||||||
"module_codes": moduleCodes,
|
"module_codes": moduleCodes,
|
||||||
"module_names": moduleNames,
|
"module_names": moduleNames,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,431 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"server/models"
|
||||||
|
"server/services"
|
||||||
|
|
||||||
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PlatformProductController 平台端「产品定价」管理
|
||||||
|
//
|
||||||
|
// 产品(名称 / 编码 / 价格 / 关联功能 / 上下架)+ 功能节点树 -> 租户端购买展示与下单。
|
||||||
|
type PlatformProductController struct {
|
||||||
|
beego.Controller
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PlatformProductController) jsonErr(httpStatus, bizCode int, msg string) {
|
||||||
|
c.Ctx.Output.SetStatus(httpStatus)
|
||||||
|
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PlatformProductController) jsonOK(data interface{}, msg string) {
|
||||||
|
if msg == "" {
|
||||||
|
msg = "success"
|
||||||
|
}
|
||||||
|
resp := map[string]interface{}{"code": 200, "msg": msg}
|
||||||
|
if data != nil {
|
||||||
|
resp["data"] = data
|
||||||
|
}
|
||||||
|
c.Data["json"] = resp
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PlatformProductController) readBody(target interface{}) bool {
|
||||||
|
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(400, 400, "参数错误")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, target); err != nil {
|
||||||
|
c.jsonErr(400, 400, "参数错误")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================ 产品 ============================
|
||||||
|
|
||||||
|
type productFeaturePayload struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
Pid uint64 `json:"pid"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Sort int `json:"sort"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type productPayload struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Price *float64 `json:"price"`
|
||||||
|
ModuleCode string `json:"module_code"`
|
||||||
|
Sort *int `json:"sort"`
|
||||||
|
Status *int8 `json:"status"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
Features []productFeaturePayload `json:"features"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadProductFeatures 批量加载产品功能节点(扁平,含 pid,前端按 pid 组装树)
|
||||||
|
func loadProductFeatures(productIDs []uint64) map[uint64][]models.PlatformProductFeature {
|
||||||
|
result := map[uint64][]models.PlatformProductFeature{}
|
||||||
|
if len(productIDs) == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
var rows []models.PlatformProductFeature
|
||||||
|
if _, err := models.Orm.QueryTable(new(models.PlatformProductFeature)).
|
||||||
|
Filter("product_id__in", productIDs).
|
||||||
|
OrderBy("sort", "id").
|
||||||
|
All(&rows); err != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
result[r.ProductID] = append(result[r.ProductID], r)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func productToMap(p models.PlatformProduct, features []models.PlatformProductFeature) map[string]interface{} {
|
||||||
|
if features == nil {
|
||||||
|
features = []models.PlatformProductFeature{}
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"id": p.ID,
|
||||||
|
"code": p.Code,
|
||||||
|
"name": p.Name,
|
||||||
|
"price": p.Price,
|
||||||
|
"module_code": p.ModuleCode,
|
||||||
|
"sort": p.Sort,
|
||||||
|
"status": p.Status,
|
||||||
|
"remark": p.Remark,
|
||||||
|
"features": features,
|
||||||
|
"create_time": p.CreateTime,
|
||||||
|
"update_time": p.UpdateTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProductList GET /platform/product/list
|
||||||
|
func (c *PlatformProductController) GetProductList() {
|
||||||
|
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||||
|
status := strings.TrimSpace(c.GetString("status"))
|
||||||
|
|
||||||
|
qs := models.Orm.QueryTable(new(models.PlatformProduct)).Filter("delete_time__isnull", true)
|
||||||
|
if keyword != "" {
|
||||||
|
qs = qs.Filter("name__icontains", keyword)
|
||||||
|
}
|
||||||
|
if status == "0" || status == "1" {
|
||||||
|
v, _ := strconv.Atoi(status)
|
||||||
|
qs = qs.Filter("status", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []models.PlatformProduct
|
||||||
|
if _, err := qs.OrderBy("sort", "id").All(&rows); err != nil {
|
||||||
|
c.jsonErr(500, 500, "获取失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]uint64, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
ids = append(ids, r.ID)
|
||||||
|
}
|
||||||
|
featureMap := loadProductFeatures(ids)
|
||||||
|
|
||||||
|
list := make([]map[string]interface{}, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
list = append(list, productToMap(r, featureMap[r.ID]))
|
||||||
|
}
|
||||||
|
c.jsonOK(map[string]interface{}{"list": list, "total": len(list)}, "获取成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProductDetail GET /platform/product/detail/:id
|
||||||
|
func (c *PlatformProductController) GetProductDetail() {
|
||||||
|
id := parseUint64Param(c.Ctx.Input.Param(":id"))
|
||||||
|
if id == 0 {
|
||||||
|
c.jsonErr(400, 400, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var row models.PlatformProduct
|
||||||
|
if err := models.Orm.QueryTable(new(models.PlatformProduct)).
|
||||||
|
Filter("id", id).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
One(&row); err != nil {
|
||||||
|
c.jsonErr(404, 404, "产品不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.jsonOK(productToMap(row, loadProductFeatures([]uint64{id})[id]), "获取成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveProductFeatures 覆盖式保存功能节点(含父子关系 pid)
|
||||||
|
// 前端传扁平数组,顶级节点 pid=0;子节点的 pid 指向父节点在库中的真实 ID,
|
||||||
|
// 因此这里先按「前端临时序号 -> 库 ID」建立映射再落库。
|
||||||
|
func saveProductFeatures(productID uint64, items []productFeaturePayload) error {
|
||||||
|
if _, err := models.Orm.QueryTable(new(models.PlatformProductFeature)).
|
||||||
|
Filter("product_id", productID).Delete(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第一遍:按输入顺序建节点,记录 oldID(库中已有ID或前端临时ID) -> newID
|
||||||
|
oldToNew := map[uint64]uint64{}
|
||||||
|
type pending struct {
|
||||||
|
item productFeaturePayload
|
||||||
|
newID uint64
|
||||||
|
}
|
||||||
|
created := make([]pending, 0, len(items))
|
||||||
|
|
||||||
|
for i, it := range items {
|
||||||
|
title := strings.TrimSpace(it.Title)
|
||||||
|
if title == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sort := it.Sort
|
||||||
|
if sort == 0 {
|
||||||
|
sort = i + 1
|
||||||
|
}
|
||||||
|
row := &models.PlatformProductFeature{
|
||||||
|
ProductID: productID,
|
||||||
|
Title: title,
|
||||||
|
Description: strings.TrimSpace(it.Description),
|
||||||
|
Sort: sort,
|
||||||
|
}
|
||||||
|
id, err := models.Orm.Insert(row)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 前端用 id 字段标识节点:已存在节点传库中真实ID,新增节点传负数临时ID,
|
||||||
|
// 统一建立「旧标识 -> 新ID」映射,供子节点回填 pid。
|
||||||
|
if it.ID != 0 {
|
||||||
|
oldToNew[it.ID] = uint64(id)
|
||||||
|
}
|
||||||
|
created = append(created, pending{item: it, newID: uint64(id)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第二遍:回填父级
|
||||||
|
for _, p := range created {
|
||||||
|
pid := p.item.Pid
|
||||||
|
if pid == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if newPid, ok := oldToNew[pid]; ok {
|
||||||
|
pid = newPid
|
||||||
|
}
|
||||||
|
// 父节点已被删除或指向自身时降级为顶级
|
||||||
|
if pid == p.newID {
|
||||||
|
pid = 0
|
||||||
|
}
|
||||||
|
if _, err := models.Orm.QueryTable(new(models.PlatformProductFeature)).
|
||||||
|
Filter("id", p.newID).
|
||||||
|
Update(map[string]interface{}{"pid": pid}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateProduct POST /platform/product/create
|
||||||
|
func (c *PlatformProductController) CreateProduct() {
|
||||||
|
var p productPayload
|
||||||
|
if !c.readBody(&p) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.Code = strings.TrimSpace(p.Code)
|
||||||
|
p.Name = strings.TrimSpace(p.Name)
|
||||||
|
if p.Name == "" {
|
||||||
|
c.jsonErr(400, 400, "产品名称不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.Code == "" {
|
||||||
|
c.jsonErr(400, 400, "产品编码不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cnt, _ := models.Orm.QueryTable(new(models.PlatformProduct)).
|
||||||
|
Filter("code", p.Code).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Count()
|
||||||
|
if cnt > 0 {
|
||||||
|
c.jsonErr(400, 400, "产品编码已存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
row := &models.PlatformProduct{
|
||||||
|
Code: p.Code,
|
||||||
|
Name: p.Name,
|
||||||
|
Price: valueFloat(p.Price, 0),
|
||||||
|
ModuleCode: strings.TrimSpace(p.ModuleCode),
|
||||||
|
Sort: valueInt(p.Sort, 0),
|
||||||
|
Status: valueInt8(p.Status, 1),
|
||||||
|
Remark: strings.TrimSpace(p.Remark),
|
||||||
|
UpdateTime: &now,
|
||||||
|
}
|
||||||
|
id, err := models.Orm.Insert(row)
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(500, 500, "创建失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.Features != nil {
|
||||||
|
if err := saveProductFeatures(uint64(id), p.Features); err != nil {
|
||||||
|
c.jsonErr(500, 500, "功能节点保存失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.jsonOK(map[string]interface{}{"id": uint64(id)}, "创建成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditProduct POST /platform/product/edit/:id
|
||||||
|
func (c *PlatformProductController) EditProduct() {
|
||||||
|
id := parseUint64Param(c.Ctx.Input.Param(":id"))
|
||||||
|
if id == 0 {
|
||||||
|
c.jsonErr(400, 400, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var p productPayload
|
||||||
|
if !c.readBody(&p) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.Code = strings.TrimSpace(p.Code)
|
||||||
|
p.Name = strings.TrimSpace(p.Name)
|
||||||
|
if p.Name == "" {
|
||||||
|
c.jsonErr(400, 400, "产品名称不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.Code != "" {
|
||||||
|
cnt, _ := models.Orm.QueryTable(new(models.PlatformProduct)).
|
||||||
|
Filter("code", p.Code).
|
||||||
|
Filter("id__ne", id).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Count()
|
||||||
|
if cnt > 0 {
|
||||||
|
c.jsonErr(400, 400, "产品编码已存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
update := map[string]interface{}{
|
||||||
|
"name": p.Name,
|
||||||
|
"module_code": strings.TrimSpace(p.ModuleCode),
|
||||||
|
"sort": valueInt(p.Sort, 0),
|
||||||
|
"status": valueInt8(p.Status, 1),
|
||||||
|
"remark": strings.TrimSpace(p.Remark),
|
||||||
|
"update_time": now,
|
||||||
|
}
|
||||||
|
if p.Code != "" {
|
||||||
|
update["code"] = p.Code
|
||||||
|
}
|
||||||
|
if p.Price != nil {
|
||||||
|
update["price"] = *p.Price
|
||||||
|
}
|
||||||
|
if _, err := models.Orm.QueryTable(new(models.PlatformProduct)).
|
||||||
|
Filter("id", id).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Update(update); err != nil {
|
||||||
|
c.jsonErr(500, 500, "编辑失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.Features != nil {
|
||||||
|
if err := saveProductFeatures(id, p.Features); err != nil {
|
||||||
|
c.jsonErr(500, 500, "功能节点保存失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.jsonOK(nil, "编辑成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteProduct DELETE /platform/product/delete/:id(软删)
|
||||||
|
func (c *PlatformProductController) DeleteProduct() {
|
||||||
|
id := parseUint64Param(c.Ctx.Input.Param(":id"))
|
||||||
|
if id == 0 {
|
||||||
|
c.jsonErr(400, 400, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
n, err := models.Orm.QueryTable(new(models.PlatformProduct)).
|
||||||
|
Filter("id", id).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Update(map[string]interface{}{"delete_time": now, "update_time": now})
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(500, 500, "删除失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
c.jsonErr(404, 404, "产品不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.PlatformProductFeature)).Filter("product_id", id).Delete()
|
||||||
|
c.jsonOK(nil, "删除成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================ 购买订单 ============================
|
||||||
|
|
||||||
|
// GetProductOrders GET /platform/product/orders?status=&keyword=
|
||||||
|
func (c *PlatformProductController) GetProductOrders() {
|
||||||
|
status := strings.TrimSpace(c.GetString("status"))
|
||||||
|
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||||
|
|
||||||
|
qs := models.Orm.QueryTable(new(models.PlatformProductOrder)).Filter("delete_time__isnull", true)
|
||||||
|
if status != "" {
|
||||||
|
qs = qs.Filter("status", status)
|
||||||
|
}
|
||||||
|
if keyword != "" {
|
||||||
|
qs = qs.Filter("product_name__icontains", keyword)
|
||||||
|
}
|
||||||
|
var rows []models.PlatformProductOrder
|
||||||
|
if _, err := qs.OrderBy("-id").Limit(200).All(&rows); err != nil {
|
||||||
|
c.jsonErr(500, 500, "获取失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.jsonOK(map[string]interface{}{"list": rows, "total": len(rows)}, "获取成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivateProductOrder POST /platform/product/order/activate/:id
|
||||||
|
// 手动开通:将订单置为已支付并绑定套餐(支付回调失败/线下付款时使用)
|
||||||
|
func (c *PlatformProductController) ActivateProductOrder() {
|
||||||
|
id := parseUint64Param(c.Ctx.Input.Param(":id"))
|
||||||
|
if id == 0 {
|
||||||
|
c.jsonErr(400, 400, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var order models.PlatformProductOrder
|
||||||
|
if err := models.Orm.QueryTable(new(models.PlatformProductOrder)).
|
||||||
|
Filter("id", id).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
One(&order); err != nil {
|
||||||
|
c.jsonErr(404, 404, "订单不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if order.Status == models.ProductOrderStatusActivated {
|
||||||
|
c.jsonErr(400, 400, "订单已开通")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := activateProductOrder(&order, "平台手动开通"); err != nil {
|
||||||
|
c.jsonErr(400, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.jsonOK(nil, "开通成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// activateProductOrder 订单开通:绑定套餐(产品配置了套餐时)并置为已开通
|
||||||
|
func activateProductOrder(order *models.PlatformProductOrder, operator string) error {
|
||||||
|
if order.PackageID > 0 && order.Tid > 0 {
|
||||||
|
if _, err := services.ApplyPackageToTenant(order.Tid, order.PackageID, true); err != nil {
|
||||||
|
return fmt.Errorf("开通失败:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
order.Status = models.ProductOrderStatusActivated
|
||||||
|
order.ActivateTime = &now
|
||||||
|
order.UpdateTime = &now
|
||||||
|
order.Remark = strings.TrimSpace(order.Remark + " " + operator)
|
||||||
|
_, err := models.Orm.Update(order, "Status", "ActivateTime", "Remark", "UpdateTime")
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -32,6 +33,9 @@ type tenantDTO struct {
|
|||||||
// 套餐与用户数配额
|
// 套餐与用户数配额
|
||||||
PackageID uint64 `json:"package_id"`
|
PackageID uint64 `json:"package_id"`
|
||||||
PackageName string `json:"package_name"`
|
PackageName string `json:"package_name"`
|
||||||
|
// PackageExpireTime 套餐计费到期时间(NULL-永久有效);DaysRemaining 剩余天数
|
||||||
|
PackageExpireTime *time.Time `json:"package_expire_time"`
|
||||||
|
DaysRemaining int `json:"days_remaining"`
|
||||||
UserQuota int `json:"user_quota"`
|
UserQuota int `json:"user_quota"`
|
||||||
UserUsed int `json:"user_used"`
|
UserUsed int `json:"user_used"`
|
||||||
Remark string `json:"remark"`
|
Remark string `json:"remark"`
|
||||||
@@ -82,6 +86,13 @@ func stringPtr(s string) *string {
|
|||||||
func toTenantDTO(t models.SystemTenant) tenantDTO {
|
func toTenantDTO(t models.SystemTenant) tenantDTO {
|
||||||
ct := t.CreateTime
|
ct := t.CreateTime
|
||||||
ut := t.UpdateTime
|
ut := t.UpdateTime
|
||||||
|
daysRemaining := 0
|
||||||
|
if t.PackageExpireTime != nil {
|
||||||
|
daysRemaining = int(math.Ceil(time.Until(*t.PackageExpireTime).Hours() / 24))
|
||||||
|
if daysRemaining < 0 {
|
||||||
|
daysRemaining = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
return tenantDTO{
|
return tenantDTO{
|
||||||
ID: t.ID,
|
ID: t.ID,
|
||||||
TenantCode: t.TenantCode,
|
TenantCode: t.TenantCode,
|
||||||
@@ -93,6 +104,8 @@ func toTenantDTO(t models.SystemTenant) tenantDTO {
|
|||||||
Worktime: stringValue(t.Worktime),
|
Worktime: stringValue(t.Worktime),
|
||||||
Status: t.Status,
|
Status: t.Status,
|
||||||
PackageID: t.PackageID,
|
PackageID: t.PackageID,
|
||||||
|
PackageExpireTime: t.PackageExpireTime,
|
||||||
|
DaysRemaining: daysRemaining,
|
||||||
UserQuota: t.UserQuota,
|
UserQuota: t.UserQuota,
|
||||||
Remark: stringValue(t.Remark),
|
Remark: stringValue(t.Remark),
|
||||||
CreateTime: &ct,
|
CreateTime: &ct,
|
||||||
@@ -188,6 +201,11 @@ func (c *PlatformTenantController) GetTenantDetail() {
|
|||||||
|
|
||||||
dto := toTenantDTO(t)
|
dto := toTenantDTO(t)
|
||||||
if pkg, err := services.GetTenantPackage(t.ID); err == nil && pkg != nil {
|
if pkg, err := services.GetTenantPackage(t.ID); err == nil && pkg != nil {
|
||||||
|
if pkg.ID != t.PackageID {
|
||||||
|
// 套餐已因到期自动降级,原到期时间不再有效
|
||||||
|
dto.PackageExpireTime = nil
|
||||||
|
dto.DaysRemaining = 0
|
||||||
|
}
|
||||||
dto.PackageID = pkg.ID
|
dto.PackageID = pkg.ID
|
||||||
dto.PackageName = pkg.Name
|
dto.PackageName = pkg.Name
|
||||||
}
|
}
|
||||||
@@ -293,7 +311,10 @@ func (c *PlatformTenantController) CreateTenant() {
|
|||||||
// 套餐与用户数上限:未指定套餐时使用默认套餐,用户数取其包含用户数(兜底 20)
|
// 套餐与用户数上限:未指定套餐时使用默认套餐,用户数取其包含用户数(兜底 20)
|
||||||
packageID := uint64(0)
|
packageID := uint64(0)
|
||||||
userQuota := services.DefaultTenantUserQuota
|
userQuota := services.DefaultTenantUserQuota
|
||||||
if p.PackageID != nil && *p.PackageID > 0 {
|
// 显式选择的套餐按套餐有效时长计费;未指定而兜底绑定默认套餐的不设置到期时间
|
||||||
|
explicitPackage := p.PackageID != nil && *p.PackageID > 0
|
||||||
|
var packageExpire *time.Time
|
||||||
|
if explicitPackage {
|
||||||
packageID = *p.PackageID
|
packageID = *p.PackageID
|
||||||
}
|
}
|
||||||
if packageID == 0 {
|
if packageID == 0 {
|
||||||
@@ -309,9 +330,15 @@ func (c *PlatformTenantController) CreateTenant() {
|
|||||||
if err := models.Orm.QueryTable(new(models.SystemTenantPackage)).
|
if err := models.Orm.QueryTable(new(models.SystemTenantPackage)).
|
||||||
Filter("id", packageID).
|
Filter("id", packageID).
|
||||||
Filter("delete_time__isnull", true).
|
Filter("delete_time__isnull", true).
|
||||||
One(&pkg); err == nil && pkg.UserQuota > 0 {
|
One(&pkg); err == nil {
|
||||||
|
if pkg.UserQuota > 0 {
|
||||||
userQuota = pkg.UserQuota
|
userQuota = pkg.UserQuota
|
||||||
}
|
}
|
||||||
|
if explicitPackage {
|
||||||
|
e := services.PackageExpireFromNow(&pkg)
|
||||||
|
packageExpire = &e
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if p.UserQuota != nil && *p.UserQuota > 0 {
|
if p.UserQuota != nil && *p.UserQuota > 0 {
|
||||||
userQuota = *p.UserQuota
|
userQuota = *p.UserQuota
|
||||||
@@ -330,6 +357,7 @@ func (c *PlatformTenantController) CreateTenant() {
|
|||||||
Worktime: stringPtr(p.Worktime),
|
Worktime: stringPtr(p.Worktime),
|
||||||
Status: status,
|
Status: status,
|
||||||
PackageID: packageID,
|
PackageID: packageID,
|
||||||
|
PackageExpireTime: packageExpire,
|
||||||
UserQuota: userQuota,
|
UserQuota: userQuota,
|
||||||
Remark: stringPtr(p.Remark),
|
Remark: stringPtr(p.Remark),
|
||||||
}
|
}
|
||||||
@@ -381,17 +409,17 @@ func (c *PlatformTenantController) EditTenant() {
|
|||||||
if p.PackageID != nil {
|
if p.PackageID != nil {
|
||||||
pkgID := *p.PackageID
|
pkgID := *p.PackageID
|
||||||
if pkgID > 0 {
|
if pkgID > 0 {
|
||||||
var pkg models.SystemTenantPackage
|
// 套餐切换/续费:按套餐有效时长重新计费(同套餐未到期在原到期时间上顺延),不动用户数上限
|
||||||
if err := models.Orm.QueryTable(new(models.SystemTenantPackage)).
|
if _, err := services.ApplyPackageToTenant(id, pkgID, false); err != nil {
|
||||||
Filter("id", pkgID).
|
c.Data["json"] = map[string]interface{}{"code": 400, "msg": err.Error()}
|
||||||
Filter("delete_time__isnull", true).
|
|
||||||
One(&pkg); err != nil {
|
|
||||||
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "套餐不存在"}
|
|
||||||
_ = c.ServeJSON()
|
_ = c.ServeJSON()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// 解绑套餐:回退未绑定状态(视为默认套餐),清除计费到期时间
|
||||||
|
update["package_id"] = 0
|
||||||
|
update["package_expire_time"] = nil
|
||||||
}
|
}
|
||||||
update["package_id"] = pkgID
|
|
||||||
}
|
}
|
||||||
if p.UserQuota != nil {
|
if p.UserQuota != nil {
|
||||||
// 用户数上限只增不减:不允许调低到当前已开用户数以下
|
// 用户数上限只增不减:不允许调低到当前已开用户数以下
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ type tenantPackagePayload struct {
|
|||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Price *float64 `json:"price"`
|
Price *float64 `json:"price"`
|
||||||
|
DurationDays *int `json:"duration_days"`
|
||||||
UserQuota *int `json:"user_quota"`
|
UserQuota *int `json:"user_quota"`
|
||||||
ExtraUserPrice *float64 `json:"extra_user_price"`
|
ExtraUserPrice *float64 `json:"extra_user_price"`
|
||||||
IsDefault *int8 `json:"is_default"`
|
IsDefault *int8 `json:"is_default"`
|
||||||
@@ -108,6 +109,14 @@ type tenantPackagePayload struct {
|
|||||||
Modules []string `json:"modules"`
|
Modules []string `json:"modules"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizePackageDuration 套餐有效时长(天)规范化:<=0 时按 365(年付)
|
||||||
|
func normalizePackageDuration(v *int) int {
|
||||||
|
if v == nil || *v <= 0 {
|
||||||
|
return services.DefaultPackageDurationDays
|
||||||
|
}
|
||||||
|
return *v
|
||||||
|
}
|
||||||
|
|
||||||
// loadPackageModules 批量加载套餐模块(packageID -> 模块列表)
|
// loadPackageModules 批量加载套餐模块(packageID -> 模块列表)
|
||||||
func loadPackageModules(packageIDs []uint64) map[uint64][]tenantPackageModuleItem {
|
func loadPackageModules(packageIDs []uint64) map[uint64][]tenantPackageModuleItem {
|
||||||
result := map[uint64][]tenantPackageModuleItem{}
|
result := map[uint64][]tenantPackageModuleItem{}
|
||||||
@@ -180,6 +189,7 @@ func (c *PlatformTenantPackageController) GetPackageList() {
|
|||||||
"code": r.Code,
|
"code": r.Code,
|
||||||
"description": r.Description,
|
"description": r.Description,
|
||||||
"price": r.Price,
|
"price": r.Price,
|
||||||
|
"duration_days": r.DurationDays,
|
||||||
"user_quota": r.UserQuota,
|
"user_quota": r.UserQuota,
|
||||||
"extra_user_price": r.ExtraUserPrice,
|
"extra_user_price": r.ExtraUserPrice,
|
||||||
"is_default": r.IsDefault,
|
"is_default": r.IsDefault,
|
||||||
@@ -235,6 +245,7 @@ func (c *PlatformTenantPackageController) GetPackageSelectList() {
|
|||||||
"code": r.Code,
|
"code": r.Code,
|
||||||
"description": r.Description,
|
"description": r.Description,
|
||||||
"price": r.Price,
|
"price": r.Price,
|
||||||
|
"duration_days": r.DurationDays,
|
||||||
"user_quota": r.UserQuota,
|
"user_quota": r.UserQuota,
|
||||||
"extra_user_price": r.ExtraUserPrice,
|
"extra_user_price": r.ExtraUserPrice,
|
||||||
"is_default": r.IsDefault,
|
"is_default": r.IsDefault,
|
||||||
@@ -267,6 +278,27 @@ func (c *PlatformTenantPackageController) GetPackageDetail() {
|
|||||||
c.jsonOK(tenantPackageDTO{SystemTenantPackage: row, Modules: modules}, "获取成功")
|
c.jsonOK(tenantPackageDTO{SystemTenantPackage: row, Modules: modules}, "获取成功")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPurchaseURL GET /platform/tenantPackage/purchaseUrl
|
||||||
|
// 套餐购买链接:租户端未开通功能卡片「购买」按钮的跳转地址
|
||||||
|
func (c *PlatformTenantPackageController) GetPurchaseURL() {
|
||||||
|
c.jsonOK(map[string]interface{}{"purchase_url": services.GetPackagePurchaseURL()}, "获取成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SavePurchaseURL POST /platform/tenantPackage/purchaseUrl
|
||||||
|
func (c *PlatformTenantPackageController) SavePurchaseURL() {
|
||||||
|
var p struct {
|
||||||
|
PurchaseURL string `json:"purchase_url"`
|
||||||
|
}
|
||||||
|
if !c.readBody(&p) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := services.SavePackagePurchaseURL(p.PurchaseURL); err != nil {
|
||||||
|
c.jsonErr(500, 500, "保存失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.jsonOK(nil, "保存成功")
|
||||||
|
}
|
||||||
|
|
||||||
// GetModuleOptions GET /platform/tenantPackage/moduleOptions
|
// GetModuleOptions GET /platform/tenantPackage/moduleOptions
|
||||||
// 可加入套餐的功能模块清单(取 yz_system_modules 中启用且未删除的模块)
|
// 可加入套餐的功能模块清单(取 yz_system_modules 中启用且未删除的模块)
|
||||||
func (c *PlatformTenantPackageController) GetModuleOptions() {
|
func (c *PlatformTenantPackageController) GetModuleOptions() {
|
||||||
@@ -365,6 +397,7 @@ func (c *PlatformTenantPackageController) CreatePackage() {
|
|||||||
Code: p.Code,
|
Code: p.Code,
|
||||||
Description: strings.TrimSpace(p.Description),
|
Description: strings.TrimSpace(p.Description),
|
||||||
Price: valueFloat(p.Price, 0),
|
Price: valueFloat(p.Price, 0),
|
||||||
|
DurationDays: normalizePackageDuration(p.DurationDays),
|
||||||
UserQuota: valueInt(p.UserQuota, services.DefaultTenantUserQuota),
|
UserQuota: valueInt(p.UserQuota, services.DefaultTenantUserQuota),
|
||||||
ExtraUserPrice: valueFloat(p.ExtraUserPrice, services.DefaultTenantExtraUserPrice),
|
ExtraUserPrice: valueFloat(p.ExtraUserPrice, services.DefaultTenantExtraUserPrice),
|
||||||
IsDefault: valueInt8(p.IsDefault, 0),
|
IsDefault: valueInt8(p.IsDefault, 0),
|
||||||
@@ -436,6 +469,9 @@ func (c *PlatformTenantPackageController) EditPackage() {
|
|||||||
if p.Price != nil {
|
if p.Price != nil {
|
||||||
update["price"] = *p.Price
|
update["price"] = *p.Price
|
||||||
}
|
}
|
||||||
|
if p.DurationDays != nil {
|
||||||
|
update["duration_days"] = normalizePackageDuration(p.DurationDays)
|
||||||
|
}
|
||||||
if p.UserQuota != nil {
|
if p.UserQuota != nil {
|
||||||
quota := *p.UserQuota
|
quota := *p.UserQuota
|
||||||
if quota <= 0 {
|
if quota <= 0 {
|
||||||
@@ -718,6 +754,9 @@ func (c *PlatformTenantPackageController) GetTenantQuotaInfo() {
|
|||||||
"used": info.Used,
|
"used": info.Used,
|
||||||
"remaining": info.Remaining,
|
"remaining": info.Remaining,
|
||||||
"extra_user_price": info.ExtraUserPrice,
|
"extra_user_price": info.ExtraUserPrice,
|
||||||
|
"duration_days": info.DurationDays,
|
||||||
|
"package_expire_time": info.PackageExpireTime,
|
||||||
|
"days_remaining": info.DaysRemaining,
|
||||||
"quota_packages": quotaPackages,
|
"quota_packages": quotaPackages,
|
||||||
}, "获取成功")
|
}, "获取成功")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- 租户套餐按年计费(元/年)+ 到期自动切换基础套餐
|
||||||
|
-- 1) 套餐表增加有效时长(天),365 = 按年计费;2) 租户表增加套餐到期时间
|
||||||
|
|
||||||
|
-- yz_system_tenant_package 增加 duration_days
|
||||||
|
SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_tenant_package');
|
||||||
|
SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_tenant_package' AND COLUMN_NAME = 'duration_days');
|
||||||
|
SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_system_tenant_package` ADD COLUMN `duration_days` int(11) NOT NULL DEFAULT 365 COMMENT ''套餐有效时长(天),365=按年计费;到期后租户自动切换基础套餐''', 'DO 0');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- yz_system_tenant 增加 package_expire_time
|
||||||
|
SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_tenant');
|
||||||
|
SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_system_tenant' AND COLUMN_NAME = 'package_expire_time');
|
||||||
|
SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_system_tenant` ADD COLUMN `package_expire_time` datetime NULL COMMENT ''套餐到期时间(NULL-永久有效);超过时效自动切换基础套餐''', 'DO 0');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- 产品定价(platform「基础设置 → 产品定价」)
|
||||||
|
--
|
||||||
|
-- 业务说明:
|
||||||
|
-- 1. 产品(yz_platform_product):平台对外售卖的功能产品,如「进销存 ERP」「CRM 客户管理」。
|
||||||
|
-- 仅维护:产品名称、产品编码、价格、关联功能(module_code)、上下架状态。
|
||||||
|
-- 2. 功能节点(yz_platform_product_feature):产品的功能树,支持父子层级(pid)。
|
||||||
|
-- 购买页按树形展示产品包含的功能清单。
|
||||||
|
-- 3. 购买订单(yz_platform_product_order):租户下单记录。
|
||||||
|
-- 在线支付走 /backend/payment/create;未配置支付渠道时可先下单,由平台在订单里手动开通。
|
||||||
|
--
|
||||||
|
-- 本脚本可重复执行:建表用 IF NOT EXISTS,补列做存在性判断,预置数据做存在性判断。
|
||||||
|
-- 运行:mysql --default-character-set=utf8mb4 -h<host> -P<port> -u<user> -p <db> < create_platform_product_pricing.sql
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- 1) 产品表
|
||||||
|
CREATE TABLE IF NOT EXISTS `yz_platform_product` (
|
||||||
|
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
||||||
|
`code` varchar(64) NOT NULL DEFAULT '' COMMENT '产品编码(唯一,建议与功能模块 code 一致,如 erp/crm)',
|
||||||
|
`name` varchar(128) NOT NULL DEFAULT '' COMMENT '产品名称',
|
||||||
|
`price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '产品价格(元)',
|
||||||
|
`module_code` varchar(64) NOT NULL DEFAULT '' COMMENT '关联功能模块编码 yz_system_modules(code)',
|
||||||
|
`sort` int(11) NOT NULL DEFAULT 0 COMMENT '排序号,越小越靠前',
|
||||||
|
`status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '状态:1上架 0下架',
|
||||||
|
`remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
|
||||||
|
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`update_time` datetime DEFAULT NULL,
|
||||||
|
`delete_time` datetime DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_code` (`code`, `delete_time`),
|
||||||
|
KEY `idx_delete_time` (`delete_time`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='产品定价-产品表';
|
||||||
|
|
||||||
|
-- 2) 产品功能节点表(树形,pid=0 为顶级)
|
||||||
|
CREATE TABLE IF NOT EXISTS `yz_platform_product_feature` (
|
||||||
|
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
||||||
|
`product_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '产品ID 关联 yz_platform_product(id)',
|
||||||
|
`pid` bigint(20) NOT NULL DEFAULT 0 COMMENT '上级节点ID,0=顶级节点',
|
||||||
|
`title` varchar(128) NOT NULL DEFAULT '' COMMENT '功能节点标题',
|
||||||
|
`description` varchar(500) NOT NULL DEFAULT '' COMMENT '功能节点说明',
|
||||||
|
`sort` int(11) NOT NULL DEFAULT 0 COMMENT '排序号,越小越靠前',
|
||||||
|
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_product_id` (`product_id`),
|
||||||
|
KEY `idx_pid` (`pid`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='产品定价-产品功能节点表(树形)';
|
||||||
|
|
||||||
|
-- 3) 产品购买订单表
|
||||||
|
CREATE TABLE IF NOT EXISTS `yz_platform_product_order` (
|
||||||
|
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
||||||
|
`order_no` varchar(40) NOT NULL DEFAULT '' COMMENT '业务订单号',
|
||||||
|
`tid` bigint(20) NOT NULL DEFAULT 0 COMMENT '租户ID',
|
||||||
|
`tenant_name` varchar(128) NOT NULL DEFAULT '' COMMENT '租户名称(快照)',
|
||||||
|
`product_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '产品ID',
|
||||||
|
`product_code` varchar(64) NOT NULL DEFAULT '' COMMENT '产品编码(快照)',
|
||||||
|
`product_name` varchar(128) NOT NULL DEFAULT '' COMMENT '产品名称(快照)',
|
||||||
|
`amount` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '订单金额(元)',
|
||||||
|
`package_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '购买后绑定的套餐ID(快照)',
|
||||||
|
`pay_no` varchar(40) NOT NULL DEFAULT '' COMMENT '支付单号 yz_platform_payment_order(pay_no)',
|
||||||
|
`channel` varchar(32) NOT NULL DEFAULT '' COMMENT '支付渠道:wechat/alipay/... 空=线下',
|
||||||
|
`status` varchar(20) NOT NULL DEFAULT 'pending' COMMENT 'pending待支付 paid已支付 activated已开通 closed已关闭',
|
||||||
|
`activate_time` datetime DEFAULT NULL COMMENT '开通时间',
|
||||||
|
`remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
|
||||||
|
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`update_time` datetime DEFAULT NULL,
|
||||||
|
`delete_time` datetime DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_order_no` (`order_no`),
|
||||||
|
KEY `idx_tid` (`tid`),
|
||||||
|
KEY `idx_status` (`status`),
|
||||||
|
KEY `idx_delete_time` (`delete_time`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='产品定价-购买订单表';
|
||||||
|
|
||||||
|
-- 4) 兼容旧结构:历史库已建过产品表时补齐/清理字段
|
||||||
|
SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_product');
|
||||||
|
SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_product' AND COLUMN_NAME = 'price');
|
||||||
|
SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_platform_product` ADD COLUMN `price` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT ''产品价格(元)'' AFTER `name`', 'DO 0');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @tbl := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_product_feature');
|
||||||
|
SET @col := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_product_feature' AND COLUMN_NAME = 'pid');
|
||||||
|
SET @ddl := IF(@tbl > 0 AND @col = 0, 'ALTER TABLE `yz_platform_product_feature` ADD COLUMN `pid` bigint(20) NOT NULL DEFAULT 0 COMMENT ''上级节点ID,0=顶级节点'' AFTER `product_id`', 'DO 0');
|
||||||
|
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- 5) 平台端菜单:基础设置 → 产品定价(仅平台端可见)
|
||||||
|
-- pid=17 为「基础设置」菜单;若环境中 17 不是基础设置,请在平台端「菜单管理」里手工挂载本页面:
|
||||||
|
-- 组件路径 /basicSettings/pricing/index.vue,路径 /basicSettings/pricing
|
||||||
|
INSERT INTO `yz_system_menu` (`pid`, `title`, `path`, `component_path`, `icon`, `sort`, `status`, `is_visible`, `views`, `type`, `permission`, `creater`, `remark`, `create_time`, `update_time`)
|
||||||
|
SELECT 17, '产品定价', '/basicSettings/pricing', '/basicSettings/pricing/index.vue', 'Goods', 5, 1, 1, '[1]', 2, '', 'system', '产品定价:产品与功能节点树', NOW(), NOW()
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_menu` WHERE `path` = '/basicSettings/pricing' AND `delete_time` IS NULL) t)
|
||||||
|
AND EXISTS (SELECT 1 FROM (SELECT `id` FROM `yz_system_menu` WHERE `id` = 17 AND `delete_time` IS NULL) t2);
|
||||||
@@ -40,6 +40,10 @@ func Init(_ string) {
|
|||||||
new(SystemTenantPackageModule),
|
new(SystemTenantPackageModule),
|
||||||
new(SystemTenantUserQuotaPackage),
|
new(SystemTenantUserQuotaPackage),
|
||||||
new(SystemTenantQuotaOrder),
|
new(SystemTenantQuotaOrder),
|
||||||
|
// 产品定价(docs/sql/create_platform_product_pricing.sql)
|
||||||
|
new(PlatformProduct),
|
||||||
|
new(PlatformProductFeature),
|
||||||
|
new(PlatformProductOrder),
|
||||||
new(BackendOrganization),
|
new(BackendOrganization),
|
||||||
new(BackendEmployee),
|
new(BackendEmployee),
|
||||||
new(BackendPosition),
|
new(BackendPosition),
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 产品定价模型(表结构见 docs/sql/create_platform_product_pricing.sql)
|
||||||
|
// 产品(名称/编码/价格/关联功能/上下架) -> 功能节点树 -> 购买订单
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
// PlatformProduct 产品 yz_platform_product
|
||||||
|
type PlatformProduct struct {
|
||||||
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
|
Code string `orm:"column(code);size(64)" json:"code"`
|
||||||
|
Name string `orm:"column(name);size(128)" json:"name"`
|
||||||
|
Price float64 `orm:"column(price);digits(10);decimals(2)" json:"price"`
|
||||||
|
ModuleCode string `orm:"column(module_code);size(64)" json:"module_code"`
|
||||||
|
Sort int `orm:"column(sort);default(0)" json:"sort"`
|
||||||
|
Status int8 `orm:"column(status);default(1)" json:"status"`
|
||||||
|
Remark string `orm:"column(remark);size(255)" json:"remark"`
|
||||||
|
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
|
||||||
|
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
|
||||||
|
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *PlatformProduct) TableName() string { return "yz_platform_product" }
|
||||||
|
|
||||||
|
// PlatformProductFeature 产品功能节点(树形,pid=0 为顶级) yz_platform_product_feature
|
||||||
|
type PlatformProductFeature struct {
|
||||||
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
|
ProductID uint64 `orm:"column(product_id)" json:"product_id"`
|
||||||
|
Pid uint64 `orm:"column(pid)" json:"pid"`
|
||||||
|
Title string `orm:"column(title);size(128)" json:"title"`
|
||||||
|
Description string `orm:"column(description);size(500)" json:"description"`
|
||||||
|
Sort int `orm:"column(sort);default(0)" json:"sort"`
|
||||||
|
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *PlatformProductFeature) TableName() string { return "yz_platform_product_feature" }
|
||||||
|
|
||||||
|
// 购买订单状态
|
||||||
|
const (
|
||||||
|
ProductOrderStatusPending = "pending" // 待支付
|
||||||
|
ProductOrderStatusPaid = "paid" // 已支付(待开通)
|
||||||
|
ProductOrderStatusActivated = "activated" // 已开通
|
||||||
|
ProductOrderStatusClosed = "closed" // 已关闭
|
||||||
|
)
|
||||||
|
|
||||||
|
// PlatformProductOrder 产品购买订单 yz_platform_product_order
|
||||||
|
type PlatformProductOrder struct {
|
||||||
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
|
OrderNo string `orm:"column(order_no);size(40)" json:"order_no"`
|
||||||
|
Tid uint64 `orm:"column(tid)" json:"tid"`
|
||||||
|
TenantName string `orm:"column(tenant_name);size(128)" json:"tenant_name"`
|
||||||
|
ProductID uint64 `orm:"column(product_id)" json:"product_id"`
|
||||||
|
ProductCode string `orm:"column(product_code);size(64)" json:"product_code"`
|
||||||
|
ProductName string `orm:"column(product_name);size(128)" json:"product_name"`
|
||||||
|
Amount float64 `orm:"column(amount);digits(10);decimals(2)" json:"amount"`
|
||||||
|
PackageID uint64 `orm:"column(package_id)" json:"package_id"`
|
||||||
|
PayNo string `orm:"column(pay_no);size(40)" json:"pay_no"`
|
||||||
|
Channel string `orm:"column(channel);size(32)" json:"channel"`
|
||||||
|
Status string `orm:"column(status);size(20);default(pending)" json:"status"`
|
||||||
|
ActivateTime *time.Time `orm:"column(activate_time);type(datetime);null" json:"activate_time"`
|
||||||
|
Remark string `orm:"column(remark);size(255)" json:"remark"`
|
||||||
|
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
|
||||||
|
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
|
||||||
|
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *PlatformProductOrder) TableName() string { return "yz_platform_product_order" }
|
||||||
@@ -15,6 +15,8 @@ type SystemTenant struct {
|
|||||||
Status int8 `orm:"column(status);default(1)" json:"status"`
|
Status int8 `orm:"column(status);default(1)" json:"status"`
|
||||||
// PackageID 套餐ID:关联 yz_system_tenant_package(id),0 表示未绑定(视为默认套餐)
|
// PackageID 套餐ID:关联 yz_system_tenant_package(id),0 表示未绑定(视为默认套餐)
|
||||||
PackageID uint64 `orm:"column(package_id);default(0)" json:"package_id"`
|
PackageID uint64 `orm:"column(package_id);default(0)" json:"package_id"`
|
||||||
|
// PackageExpireTime 套餐到期时间(NULL-永久有效);超过时效自动切换基础套餐
|
||||||
|
PackageExpireTime *time.Time `orm:"column(package_expire_time);type(datetime);null" json:"package_expire_time"`
|
||||||
// UserQuota 用户数上限:默认 20,增购后累加。租户用户数达到上限后不允许再新增账号。
|
// UserQuota 用户数上限:默认 20,增购后累加。租户用户数达到上限后不允许再新增账号。
|
||||||
UserQuota int `orm:"column(user_quota);default(20)" json:"user_quota"`
|
UserQuota int `orm:"column(user_quota);default(20)" json:"user_quota"`
|
||||||
Remark *string `orm:"column(remark);size(512);null" json:"remark"`
|
Remark *string `orm:"column(remark);size(512);null" json:"remark"`
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ type SystemTenantPackage struct {
|
|||||||
Name string `orm:"column(name);size(64)" json:"name"`
|
Name string `orm:"column(name);size(64)" json:"name"`
|
||||||
Code string `orm:"column(code);size(64)" json:"code"`
|
Code string `orm:"column(code);size(64)" json:"code"`
|
||||||
Description string `orm:"column(description);size(500)" json:"description"`
|
Description string `orm:"column(description);size(500)" json:"description"`
|
||||||
Price float64 `orm:"column(price);digits(10);decimals(2)" json:"price"`
|
Price float64 `orm:"column(price);digits(10);decimals(2)" json:"price"` // 套餐售价(元/年,按有效时长一次性计费)
|
||||||
|
DurationDays int `orm:"column(duration_days);default(365)" json:"duration_days"` // 套餐有效时长(天),365=按年计费;到期后租户自动切换基础套餐
|
||||||
UserQuota int `orm:"column(user_quota);default(20)" json:"user_quota"`
|
UserQuota int `orm:"column(user_quota);default(20)" json:"user_quota"`
|
||||||
ExtraUserPrice float64 `orm:"column(extra_user_price);digits(10);decimals(2)" json:"extra_user_price"`
|
ExtraUserPrice float64 `orm:"column(extra_user_price);digits(10);decimals(2)" json:"extra_user_price"`
|
||||||
IsDefault int8 `orm:"column(is_default);default(0)" json:"is_default"`
|
IsDefault int8 `orm:"column(is_default);default(0)" json:"is_default"`
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ func Register() {
|
|||||||
RegisterAuthRoutes()
|
RegisterAuthRoutes()
|
||||||
RegisterRoleRoutes()
|
RegisterRoleRoutes()
|
||||||
RegisterPaymentRoutes()
|
RegisterPaymentRoutes()
|
||||||
|
RegisterProductRoutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterProductRoutes 注册租户端「产品购买」路由。
|
||||||
|
func RegisterProductRoutes() {
|
||||||
|
beego.Router("/backend/product/list", &controllers.BackendProductPurchaseController{}, "get:GetProductList")
|
||||||
|
beego.Router("/backend/product/detail/:code", &controllers.BackendProductPurchaseController{}, "get:GetProductDetailByCode")
|
||||||
|
beego.Router("/backend/product/order/create", &controllers.BackendProductPurchaseController{}, "post:CreateOrder")
|
||||||
|
beego.Router("/backend/product/order/status", &controllers.BackendProductPurchaseController{}, "get:GetOrderStatus")
|
||||||
|
beego.Router("/backend/product/order/list", &controllers.BackendProductPurchaseController{}, "get:GetMyOrders")
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterPaymentRoutes 注册租户端支付路由(下单页 / 收银台)。
|
// RegisterPaymentRoutes 注册租户端支付路由(下单页 / 收银台)。
|
||||||
|
|||||||
@@ -70,6 +70,16 @@ func Register() {
|
|||||||
beego.Router("/platform/tenantPackage/edit/:id", &controllers.PlatformTenantPackageController{}, "post:EditPackage")
|
beego.Router("/platform/tenantPackage/edit/:id", &controllers.PlatformTenantPackageController{}, "post:EditPackage")
|
||||||
beego.Router("/platform/tenantPackage/delete/:id", &controllers.PlatformTenantPackageController{}, "delete:DeletePackage")
|
beego.Router("/platform/tenantPackage/delete/:id", &controllers.PlatformTenantPackageController{}, "delete:DeletePackage")
|
||||||
beego.Router("/platform/tenantPackage/detail/:id", &controllers.PlatformTenantPackageController{}, "get:GetPackageDetail")
|
beego.Router("/platform/tenantPackage/detail/:id", &controllers.PlatformTenantPackageController{}, "get:GetPackageDetail")
|
||||||
|
beego.Router("/platform/tenantPackage/purchaseUrl", &controllers.PlatformTenantPackageController{}, "get:GetPurchaseURL;post:SavePurchaseURL")
|
||||||
|
|
||||||
|
// 产品定价(yz_platform_product / feature / sku / order)
|
||||||
|
beego.Router("/platform/product/list", &controllers.PlatformProductController{}, "get:GetProductList")
|
||||||
|
beego.Router("/platform/product/detail/:id", &controllers.PlatformProductController{}, "get:GetProductDetail")
|
||||||
|
beego.Router("/platform/product/create", &controllers.PlatformProductController{}, "post:CreateProduct")
|
||||||
|
beego.Router("/platform/product/edit/:id", &controllers.PlatformProductController{}, "post:EditProduct")
|
||||||
|
beego.Router("/platform/product/delete/:id", &controllers.PlatformProductController{}, "delete:DeleteProduct")
|
||||||
|
beego.Router("/platform/product/orders", &controllers.PlatformProductController{}, "get:GetProductOrders")
|
||||||
|
beego.Router("/platform/product/order/activate/:id", &controllers.PlatformProductController{}, "post:ActivateProductOrder")
|
||||||
|
|
||||||
// 用户数加购规格(yz_system_tenant_user_quota_package)
|
// 用户数加购规格(yz_system_tenant_user_quota_package)
|
||||||
beego.Router("/platform/tenantQuotaPackage/list", &controllers.PlatformTenantPackageController{}, "get:GetQuotaPackageList")
|
beego.Router("/platform/tenantQuotaPackage/list", &controllers.PlatformTenantPackageController{}, "get:GetQuotaPackageList")
|
||||||
|
|||||||
+182
-8
@@ -2,8 +2,10 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"server/models"
|
"server/models"
|
||||||
)
|
)
|
||||||
@@ -11,6 +13,9 @@ import (
|
|||||||
// DefaultTenantUserQuota 租户默认用户数上限(未配置套餐/套餐未设置时兜底)
|
// DefaultTenantUserQuota 租户默认用户数上限(未配置套餐/套餐未设置时兜底)
|
||||||
const DefaultTenantUserQuota = 20
|
const DefaultTenantUserQuota = 20
|
||||||
|
|
||||||
|
// DefaultPackageDurationDays 套餐默认有效时长(天):按年计费
|
||||||
|
const DefaultPackageDurationDays = 365
|
||||||
|
|
||||||
// DefaultTenantExtraUserPrice 单个用户增购默认单价(元/人)
|
// DefaultTenantExtraUserPrice 单个用户增购默认单价(元/人)
|
||||||
const DefaultTenantExtraUserPrice = 200.00
|
const DefaultTenantExtraUserPrice = 200.00
|
||||||
|
|
||||||
@@ -29,7 +34,26 @@ func CountTenantUsers(tid uint64) (int, error) {
|
|||||||
return int(cnt), err
|
return int(cnt), err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTenantPackage 获取租户生效套餐:优先取绑定套餐,未绑定/已删除时回退到默认套餐
|
// PackageDurationDays 套餐有效时长(天),未配置时按 365(年付)
|
||||||
|
func PackageDurationDays(pkg *models.SystemTenantPackage) int {
|
||||||
|
if pkg == nil || pkg.DurationDays <= 0 {
|
||||||
|
return DefaultPackageDurationDays
|
||||||
|
}
|
||||||
|
return pkg.DurationDays
|
||||||
|
}
|
||||||
|
|
||||||
|
// PackageExpireFromNow 从当前时间起算套餐到期时间
|
||||||
|
func PackageExpireFromNow(pkg *models.SystemTenantPackage) time.Time {
|
||||||
|
return time.Now().AddDate(0, 0, PackageDurationDays(pkg))
|
||||||
|
}
|
||||||
|
|
||||||
|
// tenantPackageExpired 租户套餐是否已超过时效(未设置到期时间视为永久有效)
|
||||||
|
func tenantPackageExpired(tenant *models.SystemTenant) bool {
|
||||||
|
return tenant.PackageExpireTime != nil && tenant.PackageExpireTime.Before(time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTenantPackage 获取租户生效套餐:优先取绑定套餐,未绑定/已删除时回退到默认套餐。
|
||||||
|
// 绑定套餐已超过时效时,自动切换回基础套餐并落库(到期时间清空),返回基础套餐。
|
||||||
func GetTenantPackage(tid uint64) (*models.SystemTenantPackage, error) {
|
func GetTenantPackage(tid uint64) (*models.SystemTenantPackage, error) {
|
||||||
if tid == 0 {
|
if tid == 0 {
|
||||||
return nil, errors.New("tid 不能为空")
|
return nil, errors.New("tid 不能为空")
|
||||||
@@ -39,6 +63,10 @@ func GetTenantPackage(tid uint64) (*models.SystemTenantPackage, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if tenant.PackageID > 0 {
|
if tenant.PackageID > 0 {
|
||||||
|
// 套餐到期:自动切换基础套餐(降级落库,保证只执行一次)
|
||||||
|
if tenantPackageExpired(&tenant) {
|
||||||
|
return downgradeExpiredTenantPackage(tid)
|
||||||
|
}
|
||||||
var pkg models.SystemTenantPackage
|
var pkg models.SystemTenantPackage
|
||||||
if err := models.Orm.QueryTable(new(models.SystemTenantPackage)).
|
if err := models.Orm.QueryTable(new(models.SystemTenantPackage)).
|
||||||
Filter("id", tenant.PackageID).
|
Filter("id", tenant.PackageID).
|
||||||
@@ -50,6 +78,35 @@ func GetTenantPackage(tid uint64) (*models.SystemTenantPackage, error) {
|
|||||||
return GetDefaultTenantPackage()
|
return GetDefaultTenantPackage()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetBasicTenantPackage 获取基础套餐(code=basic;不存在时回退默认套餐)
|
||||||
|
func GetBasicTenantPackage() (*models.SystemTenantPackage, error) {
|
||||||
|
var pkg models.SystemTenantPackage
|
||||||
|
err := models.Orm.QueryTable(new(models.SystemTenantPackage)).
|
||||||
|
Filter("code", "basic").
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Filter("status", 1).
|
||||||
|
One(&pkg)
|
||||||
|
if err == nil {
|
||||||
|
return &pkg, nil
|
||||||
|
}
|
||||||
|
return GetDefaultTenantPackage()
|
||||||
|
}
|
||||||
|
|
||||||
|
// downgradeExpiredTenantPackage 套餐到期:切换基础套餐并清除到期时间,返回基础套餐
|
||||||
|
func downgradeExpiredTenantPackage(tid uint64) (*models.SystemTenantPackage, error) {
|
||||||
|
basic, err := GetBasicTenantPackage()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.SystemTenant)).
|
||||||
|
Filter("id", tid).
|
||||||
|
Update(map[string]interface{}{
|
||||||
|
"package_id": basic.ID,
|
||||||
|
"package_expire_time": nil,
|
||||||
|
})
|
||||||
|
return basic, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetDefaultTenantPackage 获取默认套餐(is_default=1 优先,其次按排序取第一个启用套餐)
|
// GetDefaultTenantPackage 获取默认套餐(is_default=1 优先,其次按排序取第一个启用套餐)
|
||||||
func GetDefaultTenantPackage() (*models.SystemTenantPackage, error) {
|
func GetDefaultTenantPackage() (*models.SystemTenantPackage, error) {
|
||||||
var pkg models.SystemTenantPackage
|
var pkg models.SystemTenantPackage
|
||||||
@@ -168,16 +225,18 @@ type TenantQuotaInfo struct {
|
|||||||
Remaining int `json:"remaining"`
|
Remaining int `json:"remaining"`
|
||||||
ExtraUserPrice float64 `json:"extra_user_price"`
|
ExtraUserPrice float64 `json:"extra_user_price"`
|
||||||
EffectivePackageID uint64 `json:"effective_package_id"`
|
EffectivePackageID uint64 `json:"effective_package_id"`
|
||||||
|
DurationDays int `json:"duration_days"`
|
||||||
|
PackageExpireTime *time.Time `json:"package_expire_time"`
|
||||||
|
DaysRemaining int `json:"days_remaining"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTenantQuotaInfo 查询租户用户数使用情况(含生效套餐信息)
|
// GetTenantQuotaInfo 查询租户用户数使用情况(含生效套餐信息与计费到期时间)
|
||||||
func GetTenantQuotaInfo(tid uint64) (*TenantQuotaInfo, error) {
|
func GetTenantQuotaInfo(tid uint64) (*TenantQuotaInfo, error) {
|
||||||
info := &TenantQuotaInfo{Tid: tid, Quota: DefaultTenantUserQuota, ExtraUserPrice: DefaultTenantExtraUserPrice}
|
info := &TenantQuotaInfo{Tid: tid, Quota: DefaultTenantUserQuota, ExtraUserPrice: DefaultTenantExtraUserPrice}
|
||||||
var tenant models.SystemTenant
|
var tenant models.SystemTenant
|
||||||
if err := models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&tenant); err != nil {
|
if err := models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&tenant); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
info.PackageID = tenant.PackageID
|
|
||||||
if tenant.UserQuota > 0 {
|
if tenant.UserQuota > 0 {
|
||||||
info.Quota = tenant.UserQuota
|
info.Quota = tenant.UserQuota
|
||||||
}
|
}
|
||||||
@@ -190,13 +249,29 @@ func GetTenantQuotaInfo(tid uint64) (*TenantQuotaInfo, error) {
|
|||||||
if info.Remaining < 0 {
|
if info.Remaining < 0 {
|
||||||
info.Remaining = 0
|
info.Remaining = 0
|
||||||
}
|
}
|
||||||
|
// 生效套餐(到期会自动降级基础套餐并落库)
|
||||||
if pkg, err := GetTenantPackage(tid); err == nil && pkg != nil {
|
if pkg, err := GetTenantPackage(tid); err == nil && pkg != nil {
|
||||||
info.EffectivePackageID = pkg.ID
|
info.EffectivePackageID = pkg.ID
|
||||||
info.PackageName = pkg.Name
|
info.PackageName = pkg.Name
|
||||||
|
info.DurationDays = PackageDurationDays(pkg)
|
||||||
if pkg.ExtraUserPrice > 0 {
|
if pkg.ExtraUserPrice > 0 {
|
||||||
info.ExtraUserPrice = pkg.ExtraUserPrice
|
info.ExtraUserPrice = pkg.ExtraUserPrice
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 重新读取租户绑定信息(GetTenantPackage 可能已触发到期降级落库)
|
||||||
|
var fresh models.SystemTenant
|
||||||
|
if e := models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&fresh); e == nil {
|
||||||
|
tenant = fresh
|
||||||
|
}
|
||||||
|
info.PackageID = tenant.PackageID
|
||||||
|
info.PackageExpireTime = tenant.PackageExpireTime
|
||||||
|
if tenant.PackageExpireTime != nil {
|
||||||
|
remain := int(math.Ceil(time.Until(*tenant.PackageExpireTime).Hours() / 24))
|
||||||
|
if remain < 0 {
|
||||||
|
remain = 0
|
||||||
|
}
|
||||||
|
info.DaysRemaining = remain
|
||||||
|
}
|
||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,7 +298,10 @@ func IncreaseTenantQuota(tid uint64, count int) (int, int, error) {
|
|||||||
return before, after, nil
|
return before, after, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyPackageToTenant 为租户绑定套餐:同步套餐包含的用户数(不低于当前已开用户数,且不降低已有上限)
|
// ApplyPackageToTenant 为租户绑定/续费套餐:
|
||||||
|
// - 同步套餐包含的用户数(不低于当前已开用户数,且不降低已有上限,syncQuota=true 时)
|
||||||
|
// - 按套餐有效时长(duration_days,默认 365 天 = 年付)设置计费到期时间;
|
||||||
|
// 同套餐未到期时再次绑定视为续费,在原到期时间上顺延
|
||||||
func ApplyPackageToTenant(tid, packageID uint64, syncQuota bool) (int, error) {
|
func ApplyPackageToTenant(tid, packageID uint64, syncQuota bool) (int, error) {
|
||||||
if tid == 0 || packageID == 0 {
|
if tid == 0 || packageID == 0 {
|
||||||
return 0, errors.New("参数错误")
|
return 0, errors.New("参数错误")
|
||||||
@@ -235,14 +313,14 @@ func ApplyPackageToTenant(tid, packageID uint64, syncQuota bool) (int, error) {
|
|||||||
One(&pkg); err != nil {
|
One(&pkg); err != nil {
|
||||||
return 0, errors.New("套餐不存在")
|
return 0, errors.New("套餐不存在")
|
||||||
}
|
}
|
||||||
|
|
||||||
update := map[string]interface{}{"package_id": packageID}
|
|
||||||
newQuota := 0
|
|
||||||
if syncQuota {
|
|
||||||
var tenant models.SystemTenant
|
var tenant models.SystemTenant
|
||||||
if err := models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&tenant); err != nil {
|
if err := models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&tenant); err != nil {
|
||||||
return 0, errors.New("租户不存在")
|
return 0, errors.New("租户不存在")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
update := map[string]interface{}{"package_id": packageID}
|
||||||
|
newQuota := tenant.UserQuota
|
||||||
|
if syncQuota {
|
||||||
current := tenant.UserQuota
|
current := tenant.UserQuota
|
||||||
if current <= 0 {
|
if current <= 0 {
|
||||||
current = DefaultTenantUserQuota
|
current = DefaultTenantUserQuota
|
||||||
@@ -258,6 +336,13 @@ func ApplyPackageToTenant(tid, packageID uint64, syncQuota bool) (int, error) {
|
|||||||
update["user_quota"] = newQuota
|
update["user_quota"] = newQuota
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 计费时效:同套餐未到期续费 → 从原到期时间顺延;换套餐/已到期 → 从当前时间起算
|
||||||
|
base := time.Now()
|
||||||
|
if tenant.PackageID == packageID && tenant.PackageExpireTime != nil && tenant.PackageExpireTime.After(base) {
|
||||||
|
base = *tenant.PackageExpireTime
|
||||||
|
}
|
||||||
|
update["package_expire_time"] = base.AddDate(0, 0, PackageDurationDays(&pkg))
|
||||||
|
|
||||||
if _, err := models.Orm.QueryTable(new(models.SystemTenant)).
|
if _, err := models.Orm.QueryTable(new(models.SystemTenant)).
|
||||||
Filter("id", tid).
|
Filter("id", tid).
|
||||||
Update(update); err != nil {
|
Update(update); err != nil {
|
||||||
@@ -325,6 +410,95 @@ func FilterMenusByTenantModules(menus []models.SystemMenu, allModules []models.S
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarkMenusLockedByTenantModules 按租户套餐标记未开通模块的菜单:菜单保留,返回锁定菜单ID集合。
|
||||||
|
// lockedIDs 包含未开通模块对应的菜单及其全部后代菜单ID,供前端展示锁定态(而非直接剔除导致 404)。
|
||||||
|
func MarkMenusLockedByTenantModules(menus []models.SystemMenu, allModules []models.SystemModules, allowedCodes map[string]bool) ([]models.SystemMenu, map[uint64]bool) {
|
||||||
|
lockedIDs := map[uint64]bool{}
|
||||||
|
if len(menus) == 0 || len(allModules) == 0 {
|
||||||
|
return menus, lockedIDs
|
||||||
|
}
|
||||||
|
sorted := sortModulesByPathDesc(allModules)
|
||||||
|
kept := make([]models.SystemMenu, 0, len(menus))
|
||||||
|
for _, m := range menus {
|
||||||
|
path := ""
|
||||||
|
if m.Path != nil {
|
||||||
|
path = *m.Path
|
||||||
|
}
|
||||||
|
code, ok := ownerModuleCode(sorted, path)
|
||||||
|
if ok && !allowedCodes[code] {
|
||||||
|
lockedIDs[m.ID] = true
|
||||||
|
}
|
||||||
|
kept = append(kept, m)
|
||||||
|
}
|
||||||
|
if len(lockedIDs) == 0 {
|
||||||
|
return kept, lockedIDs
|
||||||
|
}
|
||||||
|
// 锁定传递给所有后代菜单
|
||||||
|
childrenOf := map[uint64][]uint64{}
|
||||||
|
for _, m := range kept {
|
||||||
|
if m.Pid > 0 {
|
||||||
|
pid := uint64(m.Pid)
|
||||||
|
childrenOf[pid] = append(childrenOf[pid], m.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
queue := make([]uint64, 0, len(lockedIDs))
|
||||||
|
for id := range lockedIDs {
|
||||||
|
queue = append(queue, id)
|
||||||
|
}
|
||||||
|
for len(queue) > 0 {
|
||||||
|
id := queue[0]
|
||||||
|
queue = queue[1:]
|
||||||
|
for _, child := range childrenOf[id] {
|
||||||
|
if !lockedIDs[child] {
|
||||||
|
lockedIDs[child] = true
|
||||||
|
queue = append(queue, child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kept, lockedIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
// PackagePurchaseURLCode 平台通用配置(yz_platform_normal_setting)中「套餐购买链接」的配置 code
|
||||||
|
const PackagePurchaseURLCode = "package_purchase_url"
|
||||||
|
|
||||||
|
// GetPackagePurchaseURL 读取平台配置的套餐购买链接。
|
||||||
|
// 租户端 home 页未开通(锁定)功能卡片的「购买」按钮跳转到该地址;未配置时返回空串。
|
||||||
|
func GetPackagePurchaseURL() string {
|
||||||
|
var s models.PlatformNormalSetting
|
||||||
|
if err := models.Orm.QueryTable(new(models.PlatformNormalSetting)).
|
||||||
|
Filter("code", PackagePurchaseURLCode).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
One(&s); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(s.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SavePackagePurchaseURL 保存平台配置的套餐购买链接(不存在则创建)
|
||||||
|
func SavePackagePurchaseURL(url string) error {
|
||||||
|
url = strings.TrimSpace(url)
|
||||||
|
var s models.PlatformNormalSetting
|
||||||
|
err := models.Orm.QueryTable(new(models.PlatformNormalSetting)).
|
||||||
|
Filter("code", PackagePurchaseURLCode).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
One(&s)
|
||||||
|
if err != nil {
|
||||||
|
s = models.PlatformNormalSetting{
|
||||||
|
Name: "套餐购买链接",
|
||||||
|
Code: PackagePurchaseURLCode,
|
||||||
|
Value: url,
|
||||||
|
Remark: "租户端未开通功能卡片「购买」按钮跳转地址",
|
||||||
|
}
|
||||||
|
_, err = models.Orm.Insert(&s)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
s.Value = url
|
||||||
|
s.UpdateTime = &now
|
||||||
|
_, err = models.Orm.Update(&s)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// ListEnabledModules 获取全部启用且未删除的模块(用于菜单归属判定)
|
// ListEnabledModules 获取全部启用且未删除的模块(用于菜单归属判定)
|
||||||
func ListEnabledModules() ([]models.SystemModules, error) {
|
func ListEnabledModules() ([]models.SystemModules, error) {
|
||||||
var rows []models.SystemModules
|
var rows []models.SystemModules
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
/*************************************************
|
||||||
|
* 产品定价(产品 / 功能节点 / 价格档位 / 购买订单)
|
||||||
|
*************************************************/
|
||||||
|
|
||||||
|
/** 产品列表 */
|
||||||
|
export function getProductList(params) {
|
||||||
|
return request({
|
||||||
|
url: "/platform/product/list",
|
||||||
|
method: "get",
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 产品详情(含功能节点与价格档位) */
|
||||||
|
export function getProductDetail(id) {
|
||||||
|
return request({
|
||||||
|
url: `/platform/product/detail/${id}`,
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增产品 */
|
||||||
|
export function createProduct(data) {
|
||||||
|
return request({
|
||||||
|
url: "/platform/product/create",
|
||||||
|
method: "post",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑产品 */
|
||||||
|
export function updateProduct(id, data) {
|
||||||
|
return request({
|
||||||
|
url: `/platform/product/edit/${id}`,
|
||||||
|
method: "post",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除产品 */
|
||||||
|
export function deleteProduct(id) {
|
||||||
|
return request({
|
||||||
|
url: `/platform/product/delete/${id}`,
|
||||||
|
method: "delete",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 购买订单列表 */
|
||||||
|
export function getProductOrders(params) {
|
||||||
|
return request({
|
||||||
|
url: "/platform/product/orders",
|
||||||
|
method: "get",
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手动开通订单 */
|
||||||
|
export function activateProductOrder(id) {
|
||||||
|
return request({
|
||||||
|
url: `/platform/product/order/activate/${id}`,
|
||||||
|
method: "post",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -63,6 +63,23 @@ export function deleteTenantPackage(id) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 套餐购买链接(租户端未开通功能「购买」按钮跳转地址) */
|
||||||
|
export function getPackagePurchaseUrl() {
|
||||||
|
return request({
|
||||||
|
url: "/platform/tenantPackage/purchaseUrl",
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存套餐购买链接 */
|
||||||
|
export function savePackagePurchaseUrl(purchaseUrl) {
|
||||||
|
return request({
|
||||||
|
url: "/platform/tenantPackage/purchaseUrl",
|
||||||
|
method: "post",
|
||||||
|
data: { purchase_url: purchaseUrl },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** 用户数加购规格列表(enabled=1 仅启用) */
|
/** 用户数加购规格列表(enabled=1 仅启用) */
|
||||||
export function getQuotaPackageList(params) {
|
export function getQuotaPackageList(params) {
|
||||||
return request({
|
return request({
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="isEdit ? '编辑产品' : '添加产品'"
|
||||||
|
width="860px"
|
||||||
|
top="5vh"
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
|
||||||
|
<el-row :gutter="12">
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="产品名称" prop="name">
|
||||||
|
<el-input v-model="form.name" placeholder="如:进销存 ERP" maxlength="60" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="产品编码" prop="code">
|
||||||
|
<el-input
|
||||||
|
v-model="form.code"
|
||||||
|
placeholder="如 erp,建议与功能模块编码一致"
|
||||||
|
maxlength="50"
|
||||||
|
:disabled="isEdit"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="价格" prop="price">
|
||||||
|
<el-input-number
|
||||||
|
v-model="form.price"
|
||||||
|
:min="0"
|
||||||
|
:max="9999999"
|
||||||
|
:precision="2"
|
||||||
|
controls-position="right"
|
||||||
|
class="full"
|
||||||
|
/>
|
||||||
|
<span class="suffix">元</span>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="关联功能" prop="module_code">
|
||||||
|
<el-select
|
||||||
|
v-model="form.module_code"
|
||||||
|
placeholder="选择关联的功能模块"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
class="full"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="m in moduleOptions"
|
||||||
|
:key="m.code"
|
||||||
|
:label="`${m.name}(${m.code})`"
|
||||||
|
:value="m.code"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-radio-group v-model="form.status">
|
||||||
|
<el-radio :value="1">上架</el-radio>
|
||||||
|
<el-radio :value="0">下架</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<el-form-item label="排序">
|
||||||
|
<el-input-number v-model="form.sort" :min="0" :max="9999" controls-position="right" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<!-- 功能节点树 -->
|
||||||
|
<el-form-item label="功能节点">
|
||||||
|
<div class="feature-box">
|
||||||
|
<div class="feature-toolbar">
|
||||||
|
<span class="toolbar-tip">支持父子层级:可在任意节点下添加子功能</span>
|
||||||
|
<el-button size="small" type="primary" @click="addFeature(0)">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
添加顶级节点
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table
|
||||||
|
:data="featureTree"
|
||||||
|
row-key="key"
|
||||||
|
default-expand-all
|
||||||
|
:tree-props="{ children: 'children' }"
|
||||||
|
size="small"
|
||||||
|
border
|
||||||
|
class="feature-table"
|
||||||
|
>
|
||||||
|
<el-table-column label="功能名称" min-width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-input v-model="row.title" placeholder="如:采购管理" size="small" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="说明" min-width="240">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-input v-model="row.description" placeholder="功能说明(可留空)" size="small" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="排序" width="110" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-input-number
|
||||||
|
v-model="row.sort"
|
||||||
|
:min="0"
|
||||||
|
:max="999"
|
||||||
|
controls-position="right"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="150" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" type="primary" link @click="addFeature(row.key)">
|
||||||
|
子功能
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="danger" link @click="removeFeature(row.key)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<el-empty description="暂无功能节点" :image-size="60" />
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="handleClose">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, ref, watch } from "vue";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
import { Plus } from "@element-plus/icons-vue";
|
||||||
|
import { createProduct, updateProduct, getProductDetail } from "@/api/product";
|
||||||
|
import { getTenantPackageModuleOptions } from "@/api/tenantPackage";
|
||||||
|
|
||||||
|
interface FeatureNode {
|
||||||
|
key: number; // 前端唯一标识:已有节点取库中ID,新增节点取负数临时ID
|
||||||
|
id: number; // 库中ID(0=新增)
|
||||||
|
pid: number; // 父节点 key(0=顶级)
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
sort: number;
|
||||||
|
children?: FeatureNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Boolean, default: false },
|
||||||
|
product: { type: Object, default: null },
|
||||||
|
});
|
||||||
|
const emit = defineEmits(["update:modelValue", "success"]);
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
const isEdit = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
const formRef = ref();
|
||||||
|
const moduleOptions = ref<any[]>([]);
|
||||||
|
|
||||||
|
let tempKey = 0;
|
||||||
|
const nextTempKey = () => -1 * ++tempKey;
|
||||||
|
|
||||||
|
const emptyForm = () => ({
|
||||||
|
id: 0,
|
||||||
|
name: "",
|
||||||
|
code: "",
|
||||||
|
price: 0,
|
||||||
|
module_code: "",
|
||||||
|
sort: 0,
|
||||||
|
status: 1,
|
||||||
|
features: [] as FeatureNode[],
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = ref<any>(emptyForm());
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
name: [{ required: true, message: "请输入产品名称", trigger: "blur" }],
|
||||||
|
code: [{ required: true, message: "请输入产品编码", trigger: "blur" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 扁平节点 -> 树(供 el-table 树形展示) */
|
||||||
|
const featureTree = computed<FeatureNode[]>(() => {
|
||||||
|
const list = form.value.features as FeatureNode[];
|
||||||
|
const map = new Map<number, FeatureNode>();
|
||||||
|
const roots: FeatureNode[] = [];
|
||||||
|
list.forEach((n) => map.set(n.key, { ...n, children: [] }));
|
||||||
|
list.forEach((n) => {
|
||||||
|
const node = map.get(n.key)!;
|
||||||
|
const parent = n.pid ? map.get(n.pid) : undefined;
|
||||||
|
if (parent) {
|
||||||
|
parent.children!.push(node);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return roots;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 收集某节点及其全部后代的 key */
|
||||||
|
const collectKeys = (key: number): number[] => {
|
||||||
|
const keys = [key];
|
||||||
|
const children = (form.value.features as FeatureNode[]).filter((n) => n.pid === key);
|
||||||
|
children.forEach((c) => keys.push(...collectKeys(c.key)));
|
||||||
|
return keys;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addFeature = (parentKey: number) => {
|
||||||
|
const list = form.value.features as FeatureNode[];
|
||||||
|
const key = nextTempKey();
|
||||||
|
list.push({
|
||||||
|
key,
|
||||||
|
id: 0,
|
||||||
|
pid: parentKey || 0,
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
sort: list.length + 1,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFeature = (key: number) => {
|
||||||
|
const keys = new Set(collectKeys(key));
|
||||||
|
form.value.features = (form.value.features as FeatureNode[]).filter((n) => !keys.has(n.key));
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadOptions = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getTenantPackageModuleOptions();
|
||||||
|
if (res.code === 200) moduleOptions.value = res.data?.list || [];
|
||||||
|
} catch {
|
||||||
|
moduleOptions.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadDetail = async (id: number) => {
|
||||||
|
const res = await getProductDetail(id);
|
||||||
|
if (res.code === 200 && res.data) {
|
||||||
|
const d = res.data;
|
||||||
|
const features: FeatureNode[] = (d.features || []).map((f: any) => ({
|
||||||
|
key: Number(f.id),
|
||||||
|
id: Number(f.id),
|
||||||
|
pid: Number(f.pid || 0),
|
||||||
|
title: f.title || "",
|
||||||
|
description: f.description || "",
|
||||||
|
sort: Number(f.sort || 0),
|
||||||
|
}));
|
||||||
|
form.value = {
|
||||||
|
id: d.id,
|
||||||
|
name: d.name || "",
|
||||||
|
code: d.code || "",
|
||||||
|
price: Number(d.price || 0),
|
||||||
|
module_code: d.module_code || "",
|
||||||
|
sort: Number(d.sort || 0),
|
||||||
|
status: Number(d.status ?? 1),
|
||||||
|
features,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
async (val) => {
|
||||||
|
visible.value = val;
|
||||||
|
if (!val) return;
|
||||||
|
await loadOptions();
|
||||||
|
if (props.product) {
|
||||||
|
isEdit.value = true;
|
||||||
|
await loadDetail(props.product.id);
|
||||||
|
} else {
|
||||||
|
isEdit.value = false;
|
||||||
|
form.value = emptyForm();
|
||||||
|
}
|
||||||
|
nextTick(() => formRef.value?.clearValidate());
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(visible, (val) => emit("update:modelValue", val));
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
visible.value = false;
|
||||||
|
form.value = emptyForm();
|
||||||
|
formRef.value?.clearValidate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPayload = () => ({
|
||||||
|
name: form.value.name,
|
||||||
|
code: form.value.code,
|
||||||
|
price: Number(form.value.price || 0),
|
||||||
|
module_code: form.value.module_code,
|
||||||
|
sort: Number(form.value.sort || 0),
|
||||||
|
status: Number(form.value.status),
|
||||||
|
features: (form.value.features as FeatureNode[])
|
||||||
|
.filter((f) => (f.title || "").trim() !== "")
|
||||||
|
.map((f) => ({
|
||||||
|
id: f.key, // 已有节点=库中ID,新增节点=负数临时ID(后端据此建立父子映射)
|
||||||
|
pid: f.pid,
|
||||||
|
title: f.title,
|
||||||
|
description: f.description,
|
||||||
|
sort: Number(f.sort || 0),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!formRef.value) return;
|
||||||
|
try {
|
||||||
|
await formRef.value.validate();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
const payload = buildPayload();
|
||||||
|
const res = isEdit.value
|
||||||
|
? await updateProduct(form.value.id, payload)
|
||||||
|
: await createProduct(payload);
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success(isEdit.value ? "编辑成功" : "创建成功");
|
||||||
|
emit("success");
|
||||||
|
handleClose();
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || "保存失败");
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || "保存失败");
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suffix {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-box {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.feature-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
.toolbar-tip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container-box">
|
||||||
|
<div class="header-bar">
|
||||||
|
<h2>产品定价</h2>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-button v-if="activeTab === 'product'" type="primary" @click="handleAdd">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
添加产品
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="refresh">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
刷新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-divider></el-divider>
|
||||||
|
|
||||||
|
<el-alert type="info" :closable="false" show-icon class="tip-alert">
|
||||||
|
<template #default>
|
||||||
|
<p>1、产品只维护「名称 / 编码 / 价格 / 关联功能 / 上下架」,功能清单在编辑弹窗里按树形维护(支持父子层级)。</p>
|
||||||
|
<p>2、产品编码建议与功能模块编码(yz_system_modules.code)一致,租户端 home 未开通卡片点「购买」即可定位到对应产品页。</p>
|
||||||
|
<p>3、租户下单支付成功后,由平台在「购买订单」里开通(订单绑定套餐后自动为租户开通对应功能)。</p>
|
||||||
|
</template>
|
||||||
|
</el-alert>
|
||||||
|
|
||||||
|
<el-tabs v-model="activeTab" class="pricing-tabs">
|
||||||
|
<!-- 产品定价 -->
|
||||||
|
<el-tab-pane label="产品定价" name="product">
|
||||||
|
<el-table :data="products" style="width: 100%" v-loading="loading">
|
||||||
|
<el-table-column prop="id" label="ID" width="70" align="center" />
|
||||||
|
<el-table-column label="产品名称" min-width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="product-name">{{ row.name }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="产品编码" width="130" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" type="info" effect="plain">{{ row.code }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="价格" width="130" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="price">¥{{ Number(row.price || 0).toFixed(2) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="关联功能" width="140" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.module_code" size="small" effect="plain">
|
||||||
|
{{ moduleName(row.module_code) }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-else class="muted">未关联</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="功能节点" width="100" align="center">
|
||||||
|
<template #default="{ row }">{{ row.features?.length || 0 }} 个</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="sort" label="排序" width="80" align="center" />
|
||||||
|
<el-table-column label="状态" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="Number(row.status) === 1 ? 'success' : 'info'" size="small">
|
||||||
|
{{ Number(row.status) === 1 ? "已上架" : "已下架" }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="150" align="center" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button>
|
||||||
|
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- 购买订单 -->
|
||||||
|
<el-tab-pane label="购买订单" name="order">
|
||||||
|
<div class="order-filter">
|
||||||
|
<el-select v-model="orderStatus" placeholder="订单状态" clearable style="width: 160px">
|
||||||
|
<el-option label="待支付" value="pending" />
|
||||||
|
<el-option label="已支付" value="paid" />
|
||||||
|
<el-option label="已开通" value="activated" />
|
||||||
|
<el-option label="已关闭" value="closed" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" @click="loadOrders">查询</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="orders" style="width: 100%" v-loading="orderLoading">
|
||||||
|
<el-table-column prop="id" label="ID" width="70" align="center" />
|
||||||
|
<el-table-column prop="order_no" label="订单号" min-width="170" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="tenant_name" label="租户" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="product_name" label="产品" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column label="金额" width="110" align="center">
|
||||||
|
<template #default="{ row }">¥{{ Number(row.amount || 0).toFixed(2) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="绑定套餐" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.package_id" size="small" effect="plain">{{ row.package_id }}</el-tag>
|
||||||
|
<span v-else class="muted">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="orderStatusType(row.status)" size="small">
|
||||||
|
{{ orderStatusText(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="create_time" label="下单时间" width="170" align="center" />
|
||||||
|
<el-table-column label="操作" width="120" align="center" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
v-if="row.status !== 'activated'"
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
@click="handleActivate(row)"
|
||||||
|
>
|
||||||
|
手动开通
|
||||||
|
</el-button>
|
||||||
|
<span v-else class="muted">已开通</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
|
||||||
|
<ProductEditDialog v-model="dialogVisible" :product="currentProduct" @success="loadProducts" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
|
import { Plus, Refresh } from "@element-plus/icons-vue";
|
||||||
|
import {
|
||||||
|
getProductList,
|
||||||
|
deleteProduct,
|
||||||
|
getProductOrders,
|
||||||
|
activateProductOrder,
|
||||||
|
} from "@/api/product";
|
||||||
|
import { getTenantPackageModuleOptions } from "@/api/tenantPackage";
|
||||||
|
import ProductEditDialog from "./components/edit.vue";
|
||||||
|
|
||||||
|
const activeTab = ref("product");
|
||||||
|
const loading = ref(false);
|
||||||
|
const orderLoading = ref(false);
|
||||||
|
const products = ref<any[]>([]);
|
||||||
|
const orders = ref<any[]>([]);
|
||||||
|
const orderStatus = ref("");
|
||||||
|
const moduleOptions = ref<any[]>([]);
|
||||||
|
|
||||||
|
const dialogVisible = ref(false);
|
||||||
|
const currentProduct = ref<any>(null);
|
||||||
|
|
||||||
|
const moduleName = (code: string) => {
|
||||||
|
const m = moduleOptions.value.find((x) => x.code === code);
|
||||||
|
return m ? m.name : code;
|
||||||
|
};
|
||||||
|
|
||||||
|
const orderStatusText = (s: string) =>
|
||||||
|
({ pending: "待支付", paid: "已支付", activated: "已开通", closed: "已关闭" }[s] || s);
|
||||||
|
const orderStatusType = (s: string) =>
|
||||||
|
({ pending: "warning", paid: "primary", activated: "success", closed: "info" }[s] || "info");
|
||||||
|
|
||||||
|
const loadModules = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getTenantPackageModuleOptions();
|
||||||
|
if (res.code === 200) moduleOptions.value = res.data?.list || [];
|
||||||
|
} catch {
|
||||||
|
moduleOptions.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadProducts = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getProductList();
|
||||||
|
if (res.code === 200) {
|
||||||
|
products.value = res.data?.list || [];
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || "获取产品列表失败");
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || "获取产品列表失败");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadOrders = async () => {
|
||||||
|
orderLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getProductOrders({ status: orderStatus.value });
|
||||||
|
if (res.code === 200) {
|
||||||
|
orders.value = res.data?.list || [];
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || "获取订单失败");
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || "获取订单失败");
|
||||||
|
} finally {
|
||||||
|
orderLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
loadProducts();
|
||||||
|
loadOrders();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
currentProduct.value = null;
|
||||||
|
dialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (row: any) => {
|
||||||
|
currentProduct.value = { ...row };
|
||||||
|
dialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (row: any) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要删除产品「${row.name}」吗?删除后租户端购买页将不再展示该产品。`,
|
||||||
|
"警告",
|
||||||
|
{ type: "warning" }
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await deleteProduct(row.id);
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success("删除成功");
|
||||||
|
loadProducts();
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || "删除失败");
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || "删除失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActivate = async (row: any) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确认手动开通订单「${row.order_no}」?开通后租户将获得对应功能。`,
|
||||||
|
"手动开通",
|
||||||
|
{ type: "warning" }
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await activateProductOrder(row.id);
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success("开通成功");
|
||||||
|
loadOrders();
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || "开通失败");
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || "开通失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadModules();
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.container-box {
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip-alert {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 4px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-tabs {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price {
|
||||||
|
color: #f56c6c;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-filter {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
<span class="field-tip">元/人,单个增购用户数时的默认单价(如 200)</span>
|
<span class="field-tip">元/人,单个增购用户数时的默认单价(如 200)</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="套餐售价" prop="price">
|
<el-form-item label="套餐价格" prop="price">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="form.price"
|
v-model="form.price"
|
||||||
:min="0"
|
:min="0"
|
||||||
@@ -70,7 +70,17 @@
|
|||||||
:precision="2"
|
:precision="2"
|
||||||
controls-position="right"
|
controls-position="right"
|
||||||
/>
|
/>
|
||||||
<span class="field-tip">元,仅作记录展示,可填 0</span>
|
<span class="field-tip">{{ priceUnitText }},绑定/续费按该价格一次性计费</span>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="有效时长" prop="duration_days">
|
||||||
|
<el-input-number
|
||||||
|
v-model="form.duration_days"
|
||||||
|
:min="1"
|
||||||
|
:max="3650"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
<span class="field-tip">天(365 = 按年计费);租户套餐到期后自动切换为基础套餐</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="套餐说明" prop="description">
|
<el-form-item label="套餐说明" prop="description">
|
||||||
@@ -147,6 +157,7 @@ const emptyForm = () => ({
|
|||||||
code: "",
|
code: "",
|
||||||
description: "",
|
description: "",
|
||||||
price: 0,
|
price: 0,
|
||||||
|
duration_days: 365,
|
||||||
user_quota: 20,
|
user_quota: 20,
|
||||||
extra_user_price: 200,
|
extra_user_price: 200,
|
||||||
is_default: 0,
|
is_default: 0,
|
||||||
@@ -158,6 +169,15 @@ const emptyForm = () => ({
|
|||||||
|
|
||||||
const form = ref<any>(emptyForm());
|
const form = ref<any>(emptyForm());
|
||||||
|
|
||||||
|
/** 价格单位随有效时长变化:365天=元/年,30=元/月,90=元/季,其余=元/N天 */
|
||||||
|
const priceUnitText = computed(() => {
|
||||||
|
const d = Number(form.value.duration_days || 365);
|
||||||
|
if (d === 365) return "元/年";
|
||||||
|
if (d === 30) return "元/月";
|
||||||
|
if (d === 90) return "元/季";
|
||||||
|
return `元 / ${d}天`;
|
||||||
|
});
|
||||||
|
|
||||||
const rules = {
|
const rules = {
|
||||||
name: [{ required: true, message: "请输入套餐名称", trigger: "blur" }],
|
name: [{ required: true, message: "请输入套餐名称", trigger: "blur" }],
|
||||||
code: [{ required: true, message: "请输入套餐编码", trigger: "blur" }],
|
code: [{ required: true, message: "请输入套餐编码", trigger: "blur" }],
|
||||||
@@ -208,6 +228,7 @@ const loadDetail = async (id: number) => {
|
|||||||
code: d.code || "",
|
code: d.code || "",
|
||||||
description: d.description || "",
|
description: d.description || "",
|
||||||
price: Number(d.price || 0),
|
price: Number(d.price || 0),
|
||||||
|
duration_days: Number(d.duration_days || 365),
|
||||||
user_quota: Number(d.user_quota || 20),
|
user_quota: Number(d.user_quota || 20),
|
||||||
extra_user_price: Number(d.extra_user_price || 0),
|
extra_user_price: Number(d.extra_user_price || 0),
|
||||||
is_default: Number(d.is_default || 0),
|
is_default: Number(d.is_default || 0),
|
||||||
|
|||||||
@@ -23,10 +23,25 @@
|
|||||||
<el-alert type="info" :closable="false" show-icon class="tip-alert">
|
<el-alert type="info" :closable="false" show-icon class="tip-alert">
|
||||||
<template #default>
|
<template #default>
|
||||||
<p>1、套餐决定租户能开通哪些功能:勾选「功能模块」后,绑定该套餐的租户端只展示对应功能菜单(如 ERP 套餐开通进销存)。</p>
|
<p>1、套餐决定租户能开通哪些功能:勾选「功能模块」后,绑定该套餐的租户端只展示对应功能菜单(如 ERP 套餐开通进销存)。</p>
|
||||||
<p>2、所有租户都有用户数上限(默认 20 人):达到上限后无法再开账号,可在「租户管理 → 租户详情 → 套餐与用户数」中按单个(如 200 元/人)或加购套餐(如 5 人 500 元)增购。</p>
|
<p>2、套餐按「有效时长」计费(默认 365 天,价格即元/年):到期后自动切换为基础套餐,续费后恢复对应功能。</p>
|
||||||
|
<p>3、所有租户都有用户数上限(默认 20 人):达到上限后无法再开账号,可在「租户管理 → 租户详情 → 套餐与用户数」中按单个(如 200 元/人)或加购套餐(如 5 人 500 元)增购。</p>
|
||||||
</template>
|
</template>
|
||||||
</el-alert>
|
</el-alert>
|
||||||
|
|
||||||
|
<div class="purchase-url-bar">
|
||||||
|
<span class="bar-label">套餐购买链接</span>
|
||||||
|
<el-input
|
||||||
|
v-model="purchaseUrl"
|
||||||
|
placeholder="租户端未开通功能卡片「购买」按钮跳转的链接,如 https://www.example.com/package"
|
||||||
|
clearable
|
||||||
|
class="url-input"
|
||||||
|
/>
|
||||||
|
<el-button type="primary" :loading="purchaseUrlSaving" @click="handleSavePurchaseUrl">
|
||||||
|
保存
|
||||||
|
</el-button>
|
||||||
|
<span class="muted">留空时租户端点击「购买」仅提示联系管理员</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-tabs v-model="activeTab" class="package-tabs">
|
<el-tabs v-model="activeTab" class="package-tabs">
|
||||||
<!-- 功能套餐 -->
|
<!-- 功能套餐 -->
|
||||||
<el-tab-pane label="功能套餐" name="package">
|
<el-tab-pane label="功能套餐" name="package">
|
||||||
@@ -45,6 +60,12 @@
|
|||||||
<el-tag size="small" type="info" effect="plain">{{ row.code }}</el-tag>
|
<el-tag size="small" type="info" effect="plain">{{ row.code }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="套餐价格" width="120" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :class="{ 'price-zero': !Number(row.price) }">{{ formatMoney(row.price) }}</span>
|
||||||
|
<span class="muted">{{ priceUnit(row) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="开通功能" min-width="260">
|
<el-table-column label="开通功能" min-width="260">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag
|
<el-tag
|
||||||
@@ -139,6 +160,8 @@ import {
|
|||||||
deleteTenantPackage,
|
deleteTenantPackage,
|
||||||
getQuotaPackageList,
|
getQuotaPackageList,
|
||||||
deleteQuotaPackage,
|
deleteQuotaPackage,
|
||||||
|
getPackagePurchaseUrl,
|
||||||
|
savePackagePurchaseUrl,
|
||||||
} from "@/api/tenantPackage";
|
} from "@/api/tenantPackage";
|
||||||
import PackageEditDialog from "./components/edit.vue";
|
import PackageEditDialog from "./components/edit.vue";
|
||||||
import QuotaEditDialog from "./components/quotaEdit.vue";
|
import QuotaEditDialog from "./components/quotaEdit.vue";
|
||||||
@@ -154,7 +177,46 @@ const quotaDialogVisible = ref(false);
|
|||||||
const currentPackage = ref<any>(null);
|
const currentPackage = ref<any>(null);
|
||||||
const currentQuota = ref<any>(null);
|
const currentQuota = ref<any>(null);
|
||||||
|
|
||||||
|
// 套餐购买链接(租户端未开通功能卡片「购买」按钮跳转地址)
|
||||||
|
const purchaseUrl = ref("");
|
||||||
|
const purchaseUrlSaving = ref(false);
|
||||||
|
|
||||||
|
const loadPurchaseUrl = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getPackagePurchaseUrl();
|
||||||
|
if (res.code === 200) {
|
||||||
|
purchaseUrl.value = res.data?.purchase_url || "";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("获取套餐购买链接失败:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSavePurchaseUrl = async () => {
|
||||||
|
purchaseUrlSaving.value = true;
|
||||||
|
try {
|
||||||
|
const res = await savePackagePurchaseUrl(purchaseUrl.value);
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success("保存成功");
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || "保存失败");
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message || "保存失败");
|
||||||
|
} finally {
|
||||||
|
purchaseUrlSaving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formatMoney = (v: any) => `¥${Number(v || 0).toFixed(2)}`;
|
const formatMoney = (v: any) => `¥${Number(v || 0).toFixed(2)}`;
|
||||||
|
/** 价格单位随有效时长变化:365天=元/年,30=元/月,90=元/季,其余=元/N天 */
|
||||||
|
const priceUnit = (row: any) => {
|
||||||
|
const d = Number(row.duration_days || 365);
|
||||||
|
if (d === 365) return "/年";
|
||||||
|
if (d === 30) return "/月";
|
||||||
|
if (d === 90) return "/季";
|
||||||
|
return `/${d}天`;
|
||||||
|
};
|
||||||
const unitPrice = (row: any) => {
|
const unitPrice = (row: any) => {
|
||||||
const count = Number(row.user_count || 0);
|
const count = Number(row.user_count || 0);
|
||||||
if (count <= 0) return 0;
|
if (count <= 0) return 0;
|
||||||
@@ -196,6 +258,7 @@ const loadQuotaPackages = async () => {
|
|||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
loadPackages();
|
loadPackages();
|
||||||
loadQuotaPackages();
|
loadQuotaPackages();
|
||||||
|
loadPurchaseUrl();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddPackage = () => {
|
const handleAddPackage = () => {
|
||||||
@@ -302,6 +365,25 @@ onMounted(refresh);
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.purchase-url-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.bar-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input {
|
||||||
|
width: 420px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.module-tag {
|
.module-tag {
|
||||||
margin: 0 6px 6px 0;
|
margin: 0 6px 6px 0;
|
||||||
}
|
}
|
||||||
@@ -315,6 +397,10 @@ onMounted(refresh);
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.price-zero {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.el-empty) {
|
:deep(.el-empty) {
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,25 @@
|
|||||||
<el-option
|
<el-option
|
||||||
v-for="p in packageOptions"
|
v-for="p in packageOptions"
|
||||||
:key="p.id"
|
:key="p.id"
|
||||||
:label="p.name"
|
:label="`${p.name}(${formatMoney(p.price)}${priceUnit(p)})`"
|
||||||
:value="p.id"
|
:value="p.id"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="套餐到期">
|
||||||
|
<template v-if="info.package_expire_time">
|
||||||
|
<span>{{ formatDateTime(info.package_expire_time) }}</span>
|
||||||
|
<el-tag
|
||||||
|
size="small"
|
||||||
|
:type="expireTagType"
|
||||||
|
effect="plain"
|
||||||
|
class="ml8"
|
||||||
|
>
|
||||||
|
剩余 {{ info.days_remaining }} 天
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
<span v-else class="muted">永久有效</span>
|
||||||
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="开通功能">
|
<el-descriptions-item label="开通功能">
|
||||||
<template v-if="info.module_names && info.module_names.length">
|
<template v-if="info.module_names && info.module_names.length">
|
||||||
<el-tag v-for="(m, i) in info.module_names" :key="i" size="small" class="module-tag">
|
<el-tag v-for="(m, i) in info.module_names" :key="i" size="small" class="module-tag">
|
||||||
@@ -51,6 +65,14 @@
|
|||||||
class="quota-alert"
|
class="quota-alert"
|
||||||
title="该租户用户数已达上限,新增账号前请先增购用户数"
|
title="该租户用户数已达上限,新增账号前请先增购用户数"
|
||||||
/>
|
/>
|
||||||
|
<el-alert
|
||||||
|
v-if="expiringSoon"
|
||||||
|
type="warning"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
class="quota-alert"
|
||||||
|
title="该租户套餐即将到期,到期后将自动切换为基础套餐,请及时续费"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="section-title">增购记录</div>
|
<div class="section-title">增购记录</div>
|
||||||
<el-table :data="orders" style="width: 100%" size="small" v-loading="ordersLoading">
|
<el-table :data="orders" style="width: 100%" size="small" v-loading="ordersLoading">
|
||||||
@@ -181,6 +203,27 @@ const rechargeForm = ref({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const formatMoney = (v: any) => `¥${Number(v || 0).toFixed(2)}`;
|
const formatMoney = (v: any) => `¥${Number(v || 0).toFixed(2)}`;
|
||||||
|
/** 套餐价格单位随有效时长变化:365天=元/年,30=元/月,90=元/季,其余=元/N天 */
|
||||||
|
const priceUnit = (row: any) => {
|
||||||
|
const d = Number(row?.duration_days || 365);
|
||||||
|
if (d === 365) return "/年";
|
||||||
|
if (d === 30) return "/月";
|
||||||
|
if (d === 90) return "/季";
|
||||||
|
return `/${d}天`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 套餐剩余天数:<=30 天给出预警 */
|
||||||
|
const expiringSoon = computed(() => {
|
||||||
|
if (!info.value.package_expire_time) return false;
|
||||||
|
return Number(info.value.days_remaining || 0) <= 30;
|
||||||
|
});
|
||||||
|
|
||||||
|
const expireTagType = computed(() => {
|
||||||
|
const days = Number(info.value.days_remaining || 0);
|
||||||
|
if (days <= 7) return "danger";
|
||||||
|
if (days <= 30) return "warning";
|
||||||
|
return "success";
|
||||||
|
});
|
||||||
|
|
||||||
const usedPercent = computed(() => {
|
const usedPercent = computed(() => {
|
||||||
const quota = Number(info.value.quota || 0);
|
const quota = Number(info.value.quota || 0);
|
||||||
@@ -264,9 +307,13 @@ watch(
|
|||||||
const handleSetPackage = async (id: number) => {
|
const handleSetPackage = async (id: number) => {
|
||||||
const tid = props.tid;
|
const tid = props.tid;
|
||||||
if (tid == null || !id) return;
|
if (tid == null || !id) return;
|
||||||
|
const isRenewal = Number(id) === Number(info.value.package_id) && !!info.value.package_expire_time;
|
||||||
|
const billingTip = isRenewal
|
||||||
|
? "同套餐未到期再次绑定视为续费,将在原到期时间上顺延一个有效时长"
|
||||||
|
: "绑定套餐将按套餐有效时长计费(默认一年),到期后自动切换为基础套餐";
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
"调整套餐会改变该租户可用的功能模块(用户数上限只增不减),确定继续吗?",
|
`调整套餐会改变该租户可用的功能模块(用户数上限只增不减)。${billingTip},确定继续吗?`,
|
||||||
"提示",
|
"提示",
|
||||||
{ type: "warning" }
|
{ type: "warning" }
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user