This commit is contained in:
2026-09-16 10:37:08 +08:00
32 changed files with 3475 additions and 72 deletions
+300
View File
@@ -25,6 +25,7 @@
"pdfjs-dist": "^6.2.108",
"pinia": "^3.0.3",
"pinyin-pro": "^3.29.4",
"qrcode": "^1.5.4",
"tesseract.js": "^7.0.0",
"v-viewer": "^3.0.11",
"vue": "^3.5.22",
@@ -3306,6 +3307,42 @@
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
@@ -3495,6 +3532,15 @@
"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": {
"version": "3.0.11",
"resolved": "https://registry.npmmirror.com/canvg/-/canvg-3.0.11.tgz",
@@ -3578,6 +3624,17 @@
"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": {
"version": "1.15.0",
"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": {
"version": "4.0.0",
"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"
}
},
"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": {
"version": "0.3.7",
"resolved": "https://registry.npmmirror.com/docx-preview/-/docx-preview-0.3.7.tgz",
@@ -4519,6 +4591,12 @@
"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": {
"version": "7.0.1",
"resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
@@ -4859,6 +4937,19 @@
"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": {
"version": "1.15.11",
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz",
@@ -4981,6 +5072,15 @@
"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": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -5519,6 +5619,15 @@
"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": {
"version": "1.1.2",
"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"
}
},
"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": {
"version": "4.17.23",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz",
@@ -6427,6 +6548,42 @@
"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": {
"version": "1.7.0",
"resolved": "https://registry.npmmirror.com/package-manager-detector/-/package-manager-detector-1.7.0.tgz",
@@ -6474,6 +6631,15 @@
"integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==",
"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": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz",
@@ -6587,6 +6753,15 @@
"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": {
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/points-on-curve/-/points-on-curve-0.2.0.tgz",
@@ -6952,6 +7127,23 @@
"integrity": "sha512-uxq2BMTdnKW7jDghpLJrczCd9KDOdyghFtEEpfomqMJkUM83/N+W7sFJPJ3AxBf0mJ3xtxAycl6NW8p6F53jEw==",
"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": {
"version": "0.2.11",
"resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz",
@@ -7091,6 +7283,21 @@
"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": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz",
@@ -7317,6 +7524,12 @@
"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": {
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -7552,6 +7765,20 @@
"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": {
"version": "1.2.10",
"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"
}
},
"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": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-3.1.0.tgz",
@@ -8406,6 +8645,12 @@
"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": {
"version": "1.1.20",
"resolved": "https://registry.npmmirror.com/which-typed-array/-/which-typed-array-1.1.20.tgz",
@@ -8445,6 +8690,20 @@
"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": {
"version": "0.18.5",
"resolved": "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz",
@@ -8524,6 +8783,47 @@
"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": {
"version": "13.6.29",
"resolved": "https://registry.npmmirror.com/yjs/-/yjs-13.6.29.tgz",
+1
View File
@@ -26,6 +26,7 @@
"pdfjs-dist": "^6.2.108",
"pinia": "^3.0.3",
"pinyin-pro": "^3.29.4",
"qrcode": "^1.5.4",
"tesseract.js": "^7.0.0",
"v-viewer": "^3.0.11",
"vue": "^3.5.22",
+56
View File
@@ -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",
});
}
+27
View File
@@ -41,10 +41,12 @@
<el-menu-item
v-if="!item.children || item.children.length === 0"
:index="item.path || item.id.toString()"
:class="{ 'is-locked': item.locked }"
>
<i v-if="item.icon" :class="item.icon" class="menu-icon"></i>
<template #title>
<span>{{ item.title }}</span>
<i v-if="item.locked" class="fa-solid fa-lock lock-badge"></i>
</template>
</el-menu-item>
@@ -53,10 +55,12 @@
v-else
:index="item.path || item.id.toString()"
:unique-opened="true"
:class="{ 'is-locked': item.locked }"
>
<template #title>
<i v-if="item.icon" :class="item.icon" class="menu-icon"></i>
<span>{{ item.title }}</span>
<i v-if="item.locked" class="fa-solid fa-lock lock-badge"></i>
</template>
<!-- 递归渲染子菜单 -->
@@ -64,10 +68,12 @@
<el-menu-item
v-if="!child.children || child.children.length === 0"
:index="child.path || child.id.toString()"
:class="{ 'is-locked': child.locked }"
>
<i v-if="child.icon" :class="child.icon" class="menu-icon"></i>
<template #title>
<span>{{ child.title }}</span>
<i v-if="child.locked" class="fa-solid fa-lock lock-badge"></i>
</template>
</el-menu-item>
@@ -75,10 +81,12 @@
v-else
:index="child.path || child.id.toString()"
:unique-opened="true"
:class="{ 'is-locked': child.locked }"
>
<template #title>
<i v-if="child.icon" :class="child.icon" class="menu-icon"></i>
<span>{{ child.title }}</span>
<i v-if="child.locked" class="fa-solid fa-lock lock-badge"></i>
</template>
<!-- 继续递归渲染子菜单 -->
@@ -91,6 +99,7 @@
!grandchild.children || grandchild.children.length === 0
"
:index="grandchild.path || grandchild.id.toString()"
:class="{ 'is-locked': grandchild.locked }"
>
<i
v-if="grandchild.icon"
@@ -99,6 +108,7 @@
></i>
<template #title>
<span>{{ grandchild.title }}</span>
<i v-if="grandchild.locked" class="fa-solid fa-lock lock-badge"></i>
</template>
</el-menu-item>
@@ -106,6 +116,7 @@
v-else
:index="grandchild.path || grandchild.id.toString()"
:unique-opened="true"
:class="{ 'is-locked': grandchild.locked }"
>
<template #title>
<i
@@ -114,6 +125,7 @@
class="menu-icon"
></i>
<span>{{ grandchild.title }}</span>
<i v-if="grandchild.locked" class="fa-solid fa-lock lock-badge"></i>
</template>
<!-- 继续递归渲染... -->
@@ -125,6 +137,7 @@
:index="
greatGrandchild.path || greatGrandchild.id.toString()
"
:class="{ 'is-locked': greatGrandchild.locked }"
>
<i
v-if="greatGrandchild.icon"
@@ -133,6 +146,7 @@
></i>
<template #title>
<span>{{ greatGrandchild.title }}</span>
<i v-if="greatGrandchild.locked" class="fa-solid fa-lock lock-badge"></i>
</template>
</el-menu-item>
</template>
@@ -283,6 +297,7 @@ const processMenus = (menus) => {
title: menu.title,
route: menu.path,
component_path: menu.component_path,
locked: !!menu.locked,
parentId: menu.pid || 0,
order: menu.sort || 0,
children: menu.children ? processMenus(menu.children) : [],
@@ -608,6 +623,18 @@ h3 {
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 & {
.el-menu-item.is-active {
+31
View File
@@ -38,6 +38,9 @@ function resolvePageComponent(menu) {
return () => import('@/views/404/404.vue');
}
/** 套餐未开通模块的锁定占位页组件 */
const lockedPageComponent = () => import('@/views/locked/index.vue');
/**
* 菜单子节点 -> 嵌套路由(path 相对 layoutAbsPath)
*/
@@ -59,6 +62,20 @@ function nestedMenuToRoute(menu, layoutAbsPath) {
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> 导致子路由无法渲染
if (hasChildren && ownPage) {
return {
@@ -128,6 +145,20 @@ export function convertMenusToRoutes(menus, parentPath = '') {
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 子路由
if (hasChildren && ownPage) {
return {
+7
View File
@@ -157,6 +157,13 @@ const staticRoutes = [
path: "/apps/erp/dashborad",
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(.*)*",
name: "NotFound",
+138 -8
View File
@@ -75,7 +75,10 @@
@click="handleNavigate(module)"
>
<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>
<div class="card-divider"></div>
@@ -117,7 +120,10 @@
@click="handleNavigate(module)"
>
<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>
<div class="card-divider"></div>
@@ -159,7 +165,10 @@
@click="handleNavigate(module)"
>
<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>
<div class="card-divider"></div>
@@ -206,7 +215,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { ElMessage, ElMessageBox } from "element-plus";
import {
ArrowDown,
@@ -218,6 +227,7 @@ import {
Moon,
OfficeBuilding,
Lock,
Lock,
} from "@element-plus/icons-vue";
import { getTenantList } from "@/api/modules";
@@ -243,15 +253,22 @@ const router = useRouter();
const authStore = useAuthStore();
const menuStore = useMenuStore();
// 已开通模块排在前面,套餐未开通(锁定)的模块沉到后方
function sortUnlockedFirst(list: ModuleItem[]): ModuleItem[] {
return [...list].sort(
(a, b) => Number(!!a.locked) - Number(!!b.locked)
);
}
// 根据type分组模块
const basicModules = computed(() =>
moduleList.value.filter((item) => item.type === 1)
sortUnlockedFirst(moduleList.value.filter((item) => item.type === 1))
);
const systemModules = computed(() =>
moduleList.value.filter((item) => item.type === 2)
sortUnlockedFirst(moduleList.value.filter((item) => item.type === 2))
);
const uncategorizedModules = computed(() =>
moduleList.value.filter((item) => item.type === 0)
sortUnlockedFirst(moduleList.value.filter((item) => item.type === 0))
);
const moduleList = ref<ModuleItem[]>([]);
@@ -327,6 +344,25 @@ const fetchUserProfile = async () => {
// 当前租户(企业)名称,来自后端 /backend/getCurrentUser 或登录接口返回的 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) {
if (module.enabled === false) {
@@ -657,7 +693,7 @@ onMounted(() => {
}
.module-card {
height: 180px;
min-height: 180px;
background: #fff;
border-radius: 12px;
padding: 30px;
@@ -683,9 +719,17 @@ onMounted(() => {
}
.card-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 18px;
font-weight: 600;
color: @text-main;
.lock-badge {
font-size: 14px;
color: #c0c4cc;
}
}
}
@@ -708,6 +752,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 {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.15);
@@ -1140,6 +1257,19 @@ onMounted(() => {
.modules-section .module-card .card-desc {
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>
+109
View File
@@ -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>
+585
View File
@@ -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>
+36 -8
View File
@@ -113,10 +113,12 @@ func (c *BackendMenuController) GetBackendMenu() {
}
}
// 套餐功能开通过滤:仅展示租户套餐已包含的功能模块对应的菜单(未包含的整块菜单不展示)
backendMenus = filterMenusByTenantPackage(c.Ctx.Request.Header.Get("Authorization"), backendMenus, tid)
// 套餐功能开通标记:未开通模块的菜单保留但标记 locked(前端展示锁定态与升级提示,不再 404)
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()
}
@@ -139,26 +141,51 @@ func backendJWTTenantID(auth string) uint64 {
return uint64(claims.TenantId)
}
// filterMenusByTenantPackage 按租户套餐包含的功能模块过滤菜单。
// filterMenusByTenantPackage 按租户套餐包含的功能模块过滤菜单(剔除未开通模块,用于角色菜单分配等场景)。
// tid 优先取 token 中的租户ID,其次用调用方传入的兜底值;两者都取不到时不处理(保持原行为)。
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 {
tid = jwtTid
}
if tid == 0 {
return menus
return menus, nil
}
allModules, err := services.ListEnabledModules()
if err != nil || len(allModules) == 0 {
return menus
return menus, nil
}
allowed := services.GetTenantModuleCodes(tid)
// 安全兜底:租户没有绑定套餐或套餐未配置任何功能模块时不做过滤,
// 避免因套餐数据缺失导致租户端菜单整体消失。
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 数组 / 逗号分隔)
@@ -350,6 +377,7 @@ type menuNode struct {
Views []int `json:"views,omitempty"`
Type int8 `json:"type"`
Permission string `json:"permission,omitempty"`
Locked bool `json:"locked,omitempty"`
Children []*menuNode `json:"children,omitempty"`
}
+11
View File
@@ -7,10 +7,18 @@ import (
"server/models"
"server/pkg/jwtutil"
"server/services"
"server/services"
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)
type BackendModulesController struct {
beego.Controller
@@ -51,6 +59,8 @@ type moduleWithEnabled struct {
// 返回当前 backend 账号可见的模块(status=1 且 is_show=1),并根据租户绑定的套餐标记每个模块是否已开通。
// 前端可据此排序(已开通在前)、置灰未开通卡片、阻止跳转。
func (c *BackendModulesController) GetTenantList() {
claims, err := c.backendModulesClaims()
if err != nil {
claims, err := c.backendModulesClaims()
if err != nil {
c.jsonErr(401, 401, err.Error())
@@ -58,6 +68,7 @@ func (c *BackendModulesController) GetTenantList() {
}
var rows []models.SystemModules
_, err = models.Orm.QueryTable(new(models.SystemModules)).
_, err = models.Orm.QueryTable(new(models.SystemModules)).
Filter("delete_time__isnull", true).
Filter("status", 1).
+312
View File
@@ -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)}, "获取成功")
}
+12 -9
View File
@@ -47,15 +47,18 @@ func (c *BackendTenantQuotaController) GetQuotaInfo() {
"code": 200,
"msg": "success",
"data": map[string]interface{}{
"tid": info.Tid,
"package_id": info.EffectivePackageID,
"package_name": info.PackageName,
"quota": info.Quota,
"used": info.Used,
"remaining": info.Remaining,
"extra_user_price": info.ExtraUserPrice,
"module_codes": moduleCodes,
"module_names": moduleNames,
"tid": info.Tid,
"package_id": info.EffectivePackageID,
"package_name": info.PackageName,
"quota": info.Quota,
"used": info.Used,
"remaining": info.Remaining,
"extra_user_price": info.ExtraUserPrice,
"duration_days": info.DurationDays,
"package_expire_time": info.PackageExpireTime,
"days_remaining": info.DaysRemaining,
"module_codes": moduleCodes,
"module_names": moduleNames,
},
}
_ = c.ServeJSON()
+431
View File
@@ -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
}
+52 -24
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"io"
"math"
"strconv"
"strings"
"time"
@@ -32,9 +33,12 @@ type tenantDTO struct {
// 套餐与用户数配额
PackageID uint64 `json:"package_id"`
PackageName string `json:"package_name"`
UserQuota int `json:"user_quota"`
UserUsed int `json:"user_used"`
Remark string `json:"remark"`
// PackageExpireTime 套餐计费到期时间(NULL-永久有效);DaysRemaining 剩余天数
PackageExpireTime *time.Time `json:"package_expire_time"`
DaysRemaining int `json:"days_remaining"`
UserQuota int `json:"user_quota"`
UserUsed int `json:"user_used"`
Remark string `json:"remark"`
CreateTime *time.Time `json:"create_time,omitempty"`
UpdateTime *time.Time `json:"update_time,omitempty"`
DeleteTime *time.Time `json:"delete_time,omitempty"`
@@ -82,6 +86,13 @@ func stringPtr(s string) *string {
func toTenantDTO(t models.SystemTenant) tenantDTO {
ct := t.CreateTime
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{
ID: t.ID,
TenantCode: t.TenantCode,
@@ -93,6 +104,8 @@ func toTenantDTO(t models.SystemTenant) tenantDTO {
Worktime: stringValue(t.Worktime),
Status: t.Status,
PackageID: t.PackageID,
PackageExpireTime: t.PackageExpireTime,
DaysRemaining: daysRemaining,
UserQuota: t.UserQuota,
Remark: stringValue(t.Remark),
CreateTime: &ct,
@@ -188,6 +201,11 @@ func (c *PlatformTenantController) GetTenantDetail() {
dto := toTenantDTO(t)
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.PackageName = pkg.Name
}
@@ -293,7 +311,10 @@ func (c *PlatformTenantController) CreateTenant() {
// 套餐与用户数上限:未指定套餐时使用默认套餐,用户数取其包含用户数(兜底 20)
packageID := uint64(0)
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
}
if packageID == 0 {
@@ -309,8 +330,14 @@ func (c *PlatformTenantController) CreateTenant() {
if err := models.Orm.QueryTable(new(models.SystemTenantPackage)).
Filter("id", packageID).
Filter("delete_time__isnull", true).
One(&pkg); err == nil && pkg.UserQuota > 0 {
userQuota = pkg.UserQuota
One(&pkg); err == nil {
if pkg.UserQuota > 0 {
userQuota = pkg.UserQuota
}
if explicitPackage {
e := services.PackageExpireFromNow(&pkg)
packageExpire = &e
}
}
}
if p.UserQuota != nil && *p.UserQuota > 0 {
@@ -321,17 +348,18 @@ func (c *PlatformTenantController) CreateTenant() {
}
t := models.SystemTenant{
TenantCode: p.TenantCode,
TenantName: p.TenantName,
ContactPerson: stringPtr(p.ContactPerson),
ContactPhone: stringPtr(p.ContactPhone),
ContactEmail: stringPtr(p.ContactEmail),
Address: stringPtr(p.Address),
Worktime: stringPtr(p.Worktime),
Status: status,
PackageID: packageID,
UserQuota: userQuota,
Remark: stringPtr(p.Remark),
TenantCode: p.TenantCode,
TenantName: p.TenantName,
ContactPerson: stringPtr(p.ContactPerson),
ContactPhone: stringPtr(p.ContactPhone),
ContactEmail: stringPtr(p.ContactEmail),
Address: stringPtr(p.Address),
Worktime: stringPtr(p.Worktime),
Status: status,
PackageID: packageID,
PackageExpireTime: packageExpire,
UserQuota: userQuota,
Remark: stringPtr(p.Remark),
}
id, err := models.Orm.Insert(&t)
@@ -381,17 +409,17 @@ func (c *PlatformTenantController) EditTenant() {
if p.PackageID != nil {
pkgID := *p.PackageID
if pkgID > 0 {
var pkg models.SystemTenantPackage
if err := models.Orm.QueryTable(new(models.SystemTenantPackage)).
Filter("id", pkgID).
Filter("delete_time__isnull", true).
One(&pkg); err != nil {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "套餐不存在"}
// 套餐切换/续费:按套餐有效时长重新计费(同套餐未到期在原到期时间上顺延),不动用户数上限
if _, err := services.ApplyPackageToTenant(id, pkgID, false); err != nil {
c.Data["json"] = map[string]interface{}{"code": 400, "msg": err.Error()}
_ = c.ServeJSON()
return
}
} else {
// 解绑套餐:回退未绑定状态(视为默认套餐),清除计费到期时间
update["package_id"] = 0
update["package_expire_time"] = nil
}
update["package_id"] = pkgID
}
if p.UserQuota != nil {
// 用户数上限只增不减:不允许调低到当前已开用户数以下
+39
View File
@@ -99,6 +99,7 @@ type tenantPackagePayload struct {
Code string `json:"code"`
Description string `json:"description"`
Price *float64 `json:"price"`
DurationDays *int `json:"duration_days"`
UserQuota *int `json:"user_quota"`
ExtraUserPrice *float64 `json:"extra_user_price"`
IsDefault *int8 `json:"is_default"`
@@ -108,6 +109,14 @@ type tenantPackagePayload struct {
Modules []string `json:"modules"`
}
// normalizePackageDuration 套餐有效时长(天)规范化:<=0 时按 365(年付)
func normalizePackageDuration(v *int) int {
if v == nil || *v <= 0 {
return services.DefaultPackageDurationDays
}
return *v
}
// loadPackageModules 批量加载套餐模块(packageID -> 模块列表)
func loadPackageModules(packageIDs []uint64) map[uint64][]tenantPackageModuleItem {
result := map[uint64][]tenantPackageModuleItem{}
@@ -180,6 +189,7 @@ func (c *PlatformTenantPackageController) GetPackageList() {
"code": r.Code,
"description": r.Description,
"price": r.Price,
"duration_days": r.DurationDays,
"user_quota": r.UserQuota,
"extra_user_price": r.ExtraUserPrice,
"is_default": r.IsDefault,
@@ -235,6 +245,7 @@ func (c *PlatformTenantPackageController) GetPackageSelectList() {
"code": r.Code,
"description": r.Description,
"price": r.Price,
"duration_days": r.DurationDays,
"user_quota": r.UserQuota,
"extra_user_price": r.ExtraUserPrice,
"is_default": r.IsDefault,
@@ -267,6 +278,27 @@ func (c *PlatformTenantPackageController) GetPackageDetail() {
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
// 可加入套餐的功能模块清单(取 yz_system_modules 中启用且未删除的模块)
func (c *PlatformTenantPackageController) GetModuleOptions() {
@@ -365,6 +397,7 @@ func (c *PlatformTenantPackageController) CreatePackage() {
Code: p.Code,
Description: strings.TrimSpace(p.Description),
Price: valueFloat(p.Price, 0),
DurationDays: normalizePackageDuration(p.DurationDays),
UserQuota: valueInt(p.UserQuota, services.DefaultTenantUserQuota),
ExtraUserPrice: valueFloat(p.ExtraUserPrice, services.DefaultTenantExtraUserPrice),
IsDefault: valueInt8(p.IsDefault, 0),
@@ -436,6 +469,9 @@ func (c *PlatformTenantPackageController) EditPackage() {
if p.Price != nil {
update["price"] = *p.Price
}
if p.DurationDays != nil {
update["duration_days"] = normalizePackageDuration(p.DurationDays)
}
if p.UserQuota != nil {
quota := *p.UserQuota
if quota <= 0 {
@@ -718,6 +754,9 @@ func (c *PlatformTenantPackageController) GetTenantQuotaInfo() {
"used": info.Used,
"remaining": info.Remaining,
"extra_user_price": info.ExtraUserPrice,
"duration_days": info.DurationDays,
"package_expire_time": info.PackageExpireTime,
"days_remaining": info.DaysRemaining,
"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);
+4
View File
@@ -40,6 +40,10 @@ func Init(_ string) {
new(SystemTenantPackageModule),
new(SystemTenantUserQuotaPackage),
new(SystemTenantQuotaOrder),
// 产品定价(docs/sql/create_platform_product_pricing.sql)
new(PlatformProduct),
new(PlatformProductFeature),
new(PlatformProductOrder),
new(BackendOrganization),
new(BackendEmployee),
new(BackendPosition),
+69
View File
@@ -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" }
+2
View File
@@ -15,6 +15,8 @@ type SystemTenant struct {
Status int8 `orm:"column(status);default(1)" json:"status"`
// PackageID 套餐ID:关联 yz_system_tenant_package(id),0 表示未绑定(视为默认套餐)
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 int `orm:"column(user_quota);default(20)" json:"user_quota"`
Remark *string `orm:"column(remark);size(512);null" json:"remark"`
+2 -1
View File
@@ -9,7 +9,8 @@ type SystemTenantPackage struct {
Name string `orm:"column(name);size(64)" json:"name"`
Code string `orm:"column(code);size(64)" json:"code"`
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"`
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"`
+10
View File
@@ -12,6 +12,16 @@ func Register() {
RegisterAuthRoutes()
RegisterRoleRoutes()
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 注册租户端支付路由(下单页 / 收银台)。
+10
View File
@@ -70,6 +70,16 @@ func Register() {
beego.Router("/platform/tenantPackage/edit/:id", &controllers.PlatformTenantPackageController{}, "post:EditPackage")
beego.Router("/platform/tenantPackage/delete/:id", &controllers.PlatformTenantPackageController{}, "delete:DeletePackage")
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)
beego.Router("/platform/tenantQuotaPackage/list", &controllers.PlatformTenantPackageController{}, "get:GetQuotaPackageList")
+191 -17
View File
@@ -2,8 +2,10 @@ package services
import (
"errors"
"math"
"sort"
"strings"
"time"
"server/models"
)
@@ -11,6 +13,9 @@ import (
// DefaultTenantUserQuota 租户默认用户数上限(未配置套餐/套餐未设置时兜底)
const DefaultTenantUserQuota = 20
// DefaultPackageDurationDays 套餐默认有效时长(天):按年计费
const DefaultPackageDurationDays = 365
// DefaultTenantExtraUserPrice 单个用户增购默认单价(元/人)
const DefaultTenantExtraUserPrice = 200.00
@@ -29,7 +34,26 @@ func CountTenantUsers(tid uint64) (int, error) {
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) {
if tid == 0 {
return nil, errors.New("tid 不能为空")
@@ -39,6 +63,10 @@ func GetTenantPackage(tid uint64) (*models.SystemTenantPackage, error) {
return nil, err
}
if tenant.PackageID > 0 {
// 套餐到期:自动切换基础套餐(降级落库,保证只执行一次)
if tenantPackageExpired(&tenant) {
return downgradeExpiredTenantPackage(tid)
}
var pkg models.SystemTenantPackage
if err := models.Orm.QueryTable(new(models.SystemTenantPackage)).
Filter("id", tenant.PackageID).
@@ -50,6 +78,35 @@ func GetTenantPackage(tid uint64) (*models.SystemTenantPackage, error) {
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 优先,其次按排序取第一个启用套餐)
func GetDefaultTenantPackage() (*models.SystemTenantPackage, error) {
var pkg models.SystemTenantPackage
@@ -160,24 +217,26 @@ func EnsureTenantUserQuota(tid, uid uint64) error {
// GetTenantQuotaInfo 租户用户数使用情况
type TenantQuotaInfo struct {
Tid uint64 `json:"tid"`
PackageID uint64 `json:"package_id"`
PackageName string `json:"package_name"`
Quota int `json:"quota"`
Used int `json:"used"`
Remaining int `json:"remaining"`
ExtraUserPrice float64 `json:"extra_user_price"`
EffectivePackageID uint64 `json:"effective_package_id"`
Tid uint64 `json:"tid"`
PackageID uint64 `json:"package_id"`
PackageName string `json:"package_name"`
Quota int `json:"quota"`
Used int `json:"used"`
Remaining int `json:"remaining"`
ExtraUserPrice float64 `json:"extra_user_price"`
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) {
info := &TenantQuotaInfo{Tid: tid, Quota: DefaultTenantUserQuota, ExtraUserPrice: DefaultTenantExtraUserPrice}
var tenant models.SystemTenant
if err := models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&tenant); err != nil {
return nil, err
}
info.PackageID = tenant.PackageID
if tenant.UserQuota > 0 {
info.Quota = tenant.UserQuota
}
@@ -190,13 +249,29 @@ func GetTenantQuotaInfo(tid uint64) (*TenantQuotaInfo, error) {
if info.Remaining < 0 {
info.Remaining = 0
}
// 生效套餐(到期会自动降级基础套餐并落库)
if pkg, err := GetTenantPackage(tid); err == nil && pkg != nil {
info.EffectivePackageID = pkg.ID
info.PackageName = pkg.Name
info.DurationDays = PackageDurationDays(pkg)
if pkg.ExtraUserPrice > 0 {
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
}
@@ -223,7 +298,10 @@ func IncreaseTenantQuota(tid uint64, count int) (int, int, error) {
return before, after, nil
}
// ApplyPackageToTenant 为租户绑定套餐:同步套餐包含的用户数(不低于当前已开用户数,且不降低已有上限)
// ApplyPackageToTenant 为租户绑定/续费套餐:
// - 同步套餐包含的用户数(不低于当前已开用户数,且不降低已有上限,syncQuota=true 时)
// - 按套餐有效时长(duration_days,默认 365 天 = 年付)设置计费到期时间;
// 同套餐未到期时再次绑定视为续费,在原到期时间上顺延
func ApplyPackageToTenant(tid, packageID uint64, syncQuota bool) (int, error) {
if tid == 0 || packageID == 0 {
return 0, errors.New("参数错误")
@@ -235,14 +313,14 @@ func ApplyPackageToTenant(tid, packageID uint64, syncQuota bool) (int, error) {
One(&pkg); err != nil {
return 0, errors.New("套餐不存在")
}
var tenant models.SystemTenant
if err := models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&tenant); err != nil {
return 0, errors.New("租户不存在")
}
update := map[string]interface{}{"package_id": packageID}
newQuota := 0
newQuota := tenant.UserQuota
if syncQuota {
var tenant models.SystemTenant
if err := models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&tenant); err != nil {
return 0, errors.New("租户不存在")
}
current := tenant.UserQuota
if current <= 0 {
current = DefaultTenantUserQuota
@@ -258,6 +336,13 @@ func ApplyPackageToTenant(tid, packageID uint64, syncQuota bool) (int, error) {
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)).
Filter("id", tid).
Update(update); err != nil {
@@ -325,6 +410,95 @@ func FilterMenusByTenantModules(menus []models.SystemMenu, allModules []models.S
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 获取全部启用且未删除的模块(用于菜单归属判定)
func ListEnabledModules() ([]models.SystemModules, error) {
var rows []models.SystemModules
+65
View File
@@ -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",
});
}
+17
View File
@@ -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 仅启用) */
export function getQuotaPackageList(params) {
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>
</el-form-item>
<el-form-item label="套餐售价" prop="price">
<el-form-item label="套餐价格" prop="price">
<el-input-number
v-model="form.price"
:min="0"
@@ -70,7 +70,17 @@
:precision="2"
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 label="套餐说明" prop="description">
@@ -147,6 +157,7 @@ const emptyForm = () => ({
code: "",
description: "",
price: 0,
duration_days: 365,
user_quota: 20,
extra_user_price: 200,
is_default: 0,
@@ -158,6 +169,15 @@ const 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 = {
name: [{ required: true, message: "请输入套餐名称", trigger: "blur" }],
code: [{ required: true, message: "请输入套餐编码", trigger: "blur" }],
@@ -208,6 +228,7 @@ const loadDetail = async (id: number) => {
code: d.code || "",
description: d.description || "",
price: Number(d.price || 0),
duration_days: Number(d.duration_days || 365),
user_quota: Number(d.user_quota || 20),
extra_user_price: Number(d.extra_user_price || 0),
is_default: Number(d.is_default || 0),
@@ -23,10 +23,25 @@
<el-alert type="info" :closable="false" show-icon class="tip-alert">
<template #default>
<p>1、套餐决定租户能开通哪些功能:勾选「功能模块」后,绑定该套餐的租户端只展示对应功能菜单(如 ERP 套餐开通进销存)。</p>
<p>2、所有租户都有用户数上限(默认 20 人):达到上限后无法再开账号,可在「租户管理 → 租户详情 → 套餐与用户数」中按单个(如 200 元/人)或加购套餐(如 5 人 500 元)增购。</p>
<p>2、套餐按「有效时长」计费(默认 365 天,价格即元/年):到期后自动切换为基础套餐,续费后恢复对应功能。</p>
<p>3、所有租户都有用户数上限(默认 20 人):达到上限后无法再开账号,可在「租户管理 → 租户详情 → 套餐与用户数」中按单个(如 200 元/人)或加购套餐(如 5 人 500 元)增购。</p>
</template>
</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-tab-pane label="功能套餐" name="package">
@@ -45,6 +60,12 @@
<el-tag size="small" type="info" effect="plain">{{ row.code }}</el-tag>
</template>
</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">
<template #default="{ row }">
<el-tag
@@ -139,6 +160,8 @@ import {
deleteTenantPackage,
getQuotaPackageList,
deleteQuotaPackage,
getPackagePurchaseUrl,
savePackagePurchaseUrl,
} from "@/api/tenantPackage";
import PackageEditDialog from "./components/edit.vue";
import QuotaEditDialog from "./components/quotaEdit.vue";
@@ -154,7 +177,46 @@ const quotaDialogVisible = ref(false);
const currentPackage = 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)}`;
/** 价格单位随有效时长变化: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 count = Number(row.user_count || 0);
if (count <= 0) return 0;
@@ -196,6 +258,7 @@ const loadQuotaPackages = async () => {
const refresh = () => {
loadPackages();
loadQuotaPackages();
loadPurchaseUrl();
};
const handleAddPackage = () => {
@@ -311,6 +374,25 @@ onMounted(refresh);
}
}
.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 {
margin: 0 6px 6px 0;
}
@@ -324,6 +406,10 @@ onMounted(refresh);
font-size: 12px;
}
.price-zero {
color: var(--el-text-color-secondary);
}
:deep(.el-empty) {
padding: 4px 0;
}
@@ -12,11 +12,25 @@
<el-option
v-for="p in packageOptions"
:key="p.id"
:label="p.name"
:label="`${p.name}(${formatMoney(p.price)}${priceUnit(p)})`"
:value="p.id"
/>
</el-select>
</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="开通功能">
<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">
@@ -51,6 +65,14 @@
class="quota-alert"
title="该租户用户数已达上限,新增账号前请先增购用户数"
/>
<el-alert
v-if="expiringSoon"
type="warning"
show-icon
:closable="false"
class="quota-alert"
title="该租户套餐即将到期,到期后将自动切换为基础套餐,请及时续费"
/>
<div class="section-title">增购记录</div>
<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)}`;
/** 套餐价格单位随有效时长变化: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 quota = Number(info.value.quota || 0);
@@ -264,9 +307,13 @@ watch(
const handleSetPackage = async (id: number) => {
const tid = props.tid;
if (tid == null || !id) return;
const isRenewal = Number(id) === Number(info.value.package_id) && !!info.value.package_expire_time;
const billingTip = isRenewal
? "同套餐未到期再次绑定视为续费,将在原到期时间上顺延一个有效时长"
: "绑定套餐将按套餐有效时长计费(默认一年),到期后自动切换为基础套餐";
try {
await ElMessageBox.confirm(
"调整套餐会改变该租户可用的功能模块(用户数上限只增不减),确定继续吗?",
`调整套餐会改变该租户可用的功能模块(用户数上限只增不减)。${billingTip},确定继续吗?`,
"提示",
{ type: "warning" }
);