Compare commits
49 Commits
main
...
feature/zo
| Author | SHA1 | Date | |
|---|---|---|---|
| 4377d39b79 | |||
| dd000053a2 | |||
| a7eed895fd | |||
| 241c269e37 | |||
| 5103eb269f | |||
| b35beae5df | |||
| 050e1c71e4 | |||
| d49e839280 | |||
| a468b07044 | |||
| 4f0c5a2bc1 | |||
| d6f6e294a9 | |||
| 34b9a31e0f | |||
| 00650f487a | |||
| b15b5c1e6a | |||
| 22d3ecd71e | |||
| d60df0dc06 | |||
| 88fd5e0919 | |||
| ca08523e0f | |||
| 349f18082c | |||
| 660cf2acfc | |||
| 2cdf5b2ee5 | |||
| fd0d43be7e | |||
| 9d219904b7 | |||
| 6571eadc20 | |||
| 512111af3e | |||
| cbb98742ff | |||
| 33652373bd | |||
| 40c4b07510 | |||
| 2b4202b168 | |||
| 752099cf66 | |||
| 1ca87d8ec2 | |||
| f8b7e72e82 | |||
| 83f80fd96c | |||
| ef81427840 | |||
| 73d0012f0c | |||
| 7b84336f82 | |||
| 47de8cdd66 | |||
| 220852aeca | |||
| b6033c65d5 | |||
| c2b3473c5e | |||
| 30cc69f43b | |||
| 092d1991c7 | |||
| 0519de9428 | |||
| 4c27e6b4b2 | |||
| 90d6561746 | |||
| 99bc522b24 | |||
| 508c84e571 | |||
| 8be7ca9dc2 | |||
| fc7d9f9179 |
624
package-lock.json
generated
624
package-lock.json
generated
@ -9,6 +9,9 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.1",
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
|
"@vueup/vue-quill": "^1.5.5",
|
||||||
|
"@wangeditor/editor": "^5.1.23",
|
||||||
|
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"echarts": "^5.5.0",
|
"echarts": "^5.5.0",
|
||||||
@ -20,6 +23,7 @@
|
|||||||
"vue-router": "^4.3.0"
|
"vue-router": "^4.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^26.2.0",
|
||||||
"@types/qs": "^6.15.1",
|
"@types/qs": "^6.15.1",
|
||||||
"@vitejs/plugin-vue": "^5.0.4",
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
"sass": "^1.71.1",
|
"sass": "^1.71.1",
|
||||||
@ -73,6 +77,15 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/types": {
|
"node_modules/@babel/types": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz",
|
"resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz",
|
||||||
@ -1258,6 +1271,12 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@transloadit/prettier-bytes": {
|
||||||
|
"version": "0.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.0.7.tgz",
|
||||||
|
"integrity": "sha512-VeJbUb0wEKbcwaSlj5n+LscBl9IPgLPkHVGBkh00cztv6X4L/TJXK58LzFuBKX7/GAfiGhIwH67YTLTlzvIzBA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@ -1265,6 +1284,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/event-emitter": {
|
||||||
|
"version": "0.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/event-emitter/-/event-emitter-0.3.5.tgz",
|
||||||
|
"integrity": "sha512-zx2/Gg0Eg7gwEiOIIh5w9TrhKKTeQh7CPCOPNc0el4pLSwzebA8SmnHwZs2dWlLONvyulykSwGSQxQHLhjGLvQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/lodash": {
|
"node_modules/@types/lodash": {
|
||||||
"version": "4.17.24",
|
"version": "4.17.24",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz",
|
||||||
@ -1280,6 +1305,16 @@
|
|||||||
"@types/lodash": "*"
|
"@types/lodash": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "26.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
|
||||||
|
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~8.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.15.1",
|
"version": "6.15.1",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/qs/-/qs-6.15.1.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/qs/-/qs-6.15.1.tgz",
|
||||||
@ -1293,6 +1328,61 @@
|
|||||||
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
|
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@uppy/companion-client": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@uppy/companion-client/-/companion-client-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-5mTp2iq97/mYSisMaBtFRry6PTgZA6SIL7LePteOV5x0/DxKfrZW3DEiQERJmYpHzy7k8johpm2gHnEKto56Og==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@uppy/utils": "^4.1.2",
|
||||||
|
"namespace-emitter": "^2.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@uppy/core": {
|
||||||
|
"version": "2.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@uppy/core/-/core-2.3.4.tgz",
|
||||||
|
"integrity": "sha512-iWAqppC8FD8mMVqewavCz+TNaet6HPXitmGXpGGREGrakZ4FeuWytVdrelydzTdXx6vVKkOmI2FLztGg73sENQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@transloadit/prettier-bytes": "0.0.7",
|
||||||
|
"@uppy/store-default": "^2.1.1",
|
||||||
|
"@uppy/utils": "^4.1.3",
|
||||||
|
"lodash.throttle": "^4.1.1",
|
||||||
|
"mime-match": "^1.0.2",
|
||||||
|
"namespace-emitter": "^2.0.1",
|
||||||
|
"nanoid": "^3.1.25",
|
||||||
|
"preact": "^10.5.13"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@uppy/store-default": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@uppy/store-default/-/store-default-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-xnpTxvot2SeAwGwbvmJ899ASk5tYXhmZzD/aCFsXePh/v8rNvR2pKlcQUH7cF/y4baUGq3FHO/daKCok/mpKqQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@uppy/utils": {
|
||||||
|
"version": "4.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@uppy/utils/-/utils-4.1.3.tgz",
|
||||||
|
"integrity": "sha512-nTuMvwWYobnJcytDO3t+D6IkVq/Qs4Xv3vyoEZ+Iaf8gegZP+rEyoaFT2CK5XLRMienPyqRqNbIfRuFaOWSIFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash.throttle": "^4.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@uppy/xhr-upload": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@uppy/xhr-upload/-/xhr-upload-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-YWOQ6myBVPs+mhNjfdWsQyMRWUlrDLMoaG7nvf/G6Y3GKZf8AyjFDjvvJ49XWQ+DaZOftGkHmF1uh/DBeGivJQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@uppy/companion-client": "^2.2.2",
|
||||||
|
"@uppy/utils": "^4.1.2",
|
||||||
|
"nanoid": "^3.1.25"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@uppy/core": "^2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-vue": {
|
"node_modules/@vitejs/plugin-vue": {
|
||||||
"version": "5.2.4",
|
"version": "5.2.4",
|
||||||
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
|
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
|
||||||
@ -1478,6 +1568,19 @@
|
|||||||
"integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==",
|
"integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@vueup/vue-quill": {
|
||||||
|
"version": "1.5.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vueup/vue-quill/-/vue-quill-1.5.5.tgz",
|
||||||
|
"integrity": "sha512-ygA61ZZPaRzTHXi8H1w5td1XCtjwRsO6oBuJHdCbfotGQ7hBQukVp5dsYeVXWKuSiF8hGDKwIlROyNNrMalQCQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"quill": "2.0.2 || >=2.0.4 <3",
|
||||||
|
"quill-delta": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.2.41"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vueuse/core": {
|
"node_modules/@vueuse/core": {
|
||||||
"version": "14.3.0",
|
"version": "14.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.3.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.3.0.tgz",
|
||||||
@ -1516,6 +1619,165 @@
|
|||||||
"vue": "^3.5.0"
|
"vue": "^3.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@wangeditor/basic-modules": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@wangeditor/basic-modules/-/basic-modules-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-cY9CPkLJaqF05STqfpZKWG4LpxTMeGSIIF1fHvfm/mz+JXatCagjdkbxdikOuKYlxDdeqvOeBmsUBItufDLXZg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-url": "^1.2.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@wangeditor/core": "1.x",
|
||||||
|
"dom7": "^3.0.0",
|
||||||
|
"lodash.throttle": "^4.1.1",
|
||||||
|
"nanoid": "^3.2.0",
|
||||||
|
"slate": "^0.72.0",
|
||||||
|
"snabbdom": "^3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@wangeditor/code-highlight": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@wangeditor/code-highlight/-/code-highlight-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-iazHwO14XpCuIWJNTQTikqUhGKyqj+dUNWJ9288Oym9M2xMVHvnsOmDU2sgUDWVy+pOLojReMPgXCsvvNlOOhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prismjs": "^1.23.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@wangeditor/core": "1.x",
|
||||||
|
"dom7": "^3.0.0",
|
||||||
|
"slate": "^0.72.0",
|
||||||
|
"snabbdom": "^3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@wangeditor/core": {
|
||||||
|
"version": "1.1.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/@wangeditor/core/-/core-1.1.19.tgz",
|
||||||
|
"integrity": "sha512-KevkB47+7GhVszyYF2pKGKtCSj/YzmClsD03C3zTt+9SR2XWT5T0e3yQqg8baZpcMvkjs1D8Dv4fk8ok/UaS2Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/event-emitter": "^0.3.3",
|
||||||
|
"event-emitter": "^0.3.5",
|
||||||
|
"html-void-elements": "^2.0.0",
|
||||||
|
"i18next": "^20.4.0",
|
||||||
|
"scroll-into-view-if-needed": "^2.2.28",
|
||||||
|
"slate-history": "^0.66.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@uppy/core": "^2.1.1",
|
||||||
|
"@uppy/xhr-upload": "^2.0.3",
|
||||||
|
"dom7": "^3.0.0",
|
||||||
|
"is-hotkey": "^0.2.0",
|
||||||
|
"lodash.camelcase": "^4.3.0",
|
||||||
|
"lodash.clonedeep": "^4.5.0",
|
||||||
|
"lodash.debounce": "^4.0.8",
|
||||||
|
"lodash.foreach": "^4.5.0",
|
||||||
|
"lodash.isequal": "^4.5.0",
|
||||||
|
"lodash.throttle": "^4.1.1",
|
||||||
|
"lodash.toarray": "^4.4.0",
|
||||||
|
"nanoid": "^3.2.0",
|
||||||
|
"slate": "^0.72.0",
|
||||||
|
"snabbdom": "^3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@wangeditor/editor": {
|
||||||
|
"version": "5.1.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/@wangeditor/editor/-/editor-5.1.23.tgz",
|
||||||
|
"integrity": "sha512-0RxfeVTuK1tktUaPROnCoFfaHVJpRAIE2zdS0mpP+vq1axVQpLjM8+fCvKzqYIkH0Pg+C+44hJpe3VVroSkEuQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@uppy/core": "^2.1.1",
|
||||||
|
"@uppy/xhr-upload": "^2.0.3",
|
||||||
|
"@wangeditor/basic-modules": "^1.1.7",
|
||||||
|
"@wangeditor/code-highlight": "^1.0.3",
|
||||||
|
"@wangeditor/core": "^1.1.19",
|
||||||
|
"@wangeditor/list-module": "^1.0.5",
|
||||||
|
"@wangeditor/table-module": "^1.1.4",
|
||||||
|
"@wangeditor/upload-image-module": "^1.0.2",
|
||||||
|
"@wangeditor/video-module": "^1.1.4",
|
||||||
|
"dom7": "^3.0.0",
|
||||||
|
"is-hotkey": "^0.2.0",
|
||||||
|
"lodash.camelcase": "^4.3.0",
|
||||||
|
"lodash.clonedeep": "^4.5.0",
|
||||||
|
"lodash.debounce": "^4.0.8",
|
||||||
|
"lodash.foreach": "^4.5.0",
|
||||||
|
"lodash.isequal": "^4.5.0",
|
||||||
|
"lodash.throttle": "^4.1.1",
|
||||||
|
"lodash.toarray": "^4.4.0",
|
||||||
|
"nanoid": "^3.2.0",
|
||||||
|
"slate": "^0.72.0",
|
||||||
|
"snabbdom": "^3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@wangeditor/editor-for-vue": {
|
||||||
|
"version": "5.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@wangeditor/editor-for-vue/-/editor-for-vue-5.1.12.tgz",
|
||||||
|
"integrity": "sha512-0Ds3D8I+xnpNWezAeO7HmPRgTfUxHLMd9JKcIw+QzvSmhC5xUHbpCcLU+KLmeBKTR/zffnS5GQo6qi3GhTMJWQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@wangeditor/editor": ">=5.1.0",
|
||||||
|
"vue": "^3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@wangeditor/list-module": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@wangeditor/list-module/-/list-module-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-uDuYTP6DVhcYf7mF1pTlmNn5jOb4QtcVhYwSSAkyg09zqxI1qBqsfUnveeDeDqIuptSJhkh81cyxi+MF8sEPOQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@wangeditor/core": "1.x",
|
||||||
|
"dom7": "^3.0.0",
|
||||||
|
"slate": "^0.72.0",
|
||||||
|
"snabbdom": "^3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@wangeditor/table-module": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@wangeditor/table-module/-/table-module-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-5saanU9xuEocxaemGdNi9t8MCDSucnykEC6jtuiT72kt+/Hhh4nERYx1J20OPsTCCdVr7hIyQenFD1iSRkIQ6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@wangeditor/core": "1.x",
|
||||||
|
"dom7": "^3.0.0",
|
||||||
|
"lodash.isequal": "^4.5.0",
|
||||||
|
"lodash.throttle": "^4.1.1",
|
||||||
|
"nanoid": "^3.2.0",
|
||||||
|
"slate": "^0.72.0",
|
||||||
|
"snabbdom": "^3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@wangeditor/upload-image-module": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@wangeditor/upload-image-module/-/upload-image-module-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-z81lk/v71OwPDYeQDxj6cVr81aDP90aFuywb8nPD6eQeECtOymrqRODjpO6VGvCVxVck8nUxBHtbxKtjgcwyiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@uppy/core": "^2.0.3",
|
||||||
|
"@uppy/xhr-upload": "^2.0.3",
|
||||||
|
"@wangeditor/basic-modules": "1.x",
|
||||||
|
"@wangeditor/core": "1.x",
|
||||||
|
"dom7": "^3.0.0",
|
||||||
|
"lodash.foreach": "^4.5.0",
|
||||||
|
"slate": "^0.72.0",
|
||||||
|
"snabbdom": "^3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@wangeditor/video-module": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@wangeditor/video-module/-/video-module-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-ZdodDPqKQrgx3IwWu4ZiQmXI8EXZ3hm2/fM6E3t5dB8tCaIGWQZhmqd6P5knfkRAd3z2+YRSRbxOGfoRSp/rLg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@uppy/core": "^2.1.4",
|
||||||
|
"@uppy/xhr-upload": "^2.0.7",
|
||||||
|
"@wangeditor/core": "1.x",
|
||||||
|
"dom7": "^3.0.0",
|
||||||
|
"nanoid": "^3.2.0",
|
||||||
|
"slate": "^0.72.0",
|
||||||
|
"snabbdom": "^3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.17.0",
|
"version": "8.17.0",
|
||||||
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.17.0.tgz",
|
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.17.0.tgz",
|
||||||
@ -1699,6 +1961,12 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/compute-scroll-into-view": {
|
||||||
|
"version": "1.0.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
|
||||||
|
"integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/confbox": {
|
"node_modules/confbox": {
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz",
|
"resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz",
|
||||||
@ -1712,6 +1980,19 @@
|
|||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"es5-ext": "^0.10.64",
|
||||||
|
"type": "^2.7.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dayjs": {
|
"node_modules/dayjs": {
|
||||||
"version": "1.11.21",
|
"version": "1.11.21",
|
||||||
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz",
|
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz",
|
||||||
@ -1762,6 +2043,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dom7": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom7/-/dom7-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-oNlcUdHsC4zb7Msx7JN3K0Nro1dzJ48knvBOnDPKJ2GV9wl1i5vydJZUSyOfrkKFDZEud/jBsTk92S/VGSAe/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ssr-window": "^3.0.0-alpha.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@ -1869,6 +2159,46 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es5-ext": {
|
||||||
|
"version": "0.10.64",
|
||||||
|
"resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz",
|
||||||
|
"integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"es6-iterator": "^2.0.3",
|
||||||
|
"es6-symbol": "^3.1.3",
|
||||||
|
"esniff": "^2.0.1",
|
||||||
|
"next-tick": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es6-iterator": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"d": "1",
|
||||||
|
"es5-ext": "^0.10.35",
|
||||||
|
"es6-symbol": "^3.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es6-symbol": {
|
||||||
|
"version": "3.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz",
|
||||||
|
"integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d": "^1.0.2",
|
||||||
|
"ext": "^1.7.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz",
|
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz",
|
||||||
@ -1921,12 +2251,43 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/esniff": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d": "^1.0.1",
|
||||||
|
"es5-ext": "^0.10.62",
|
||||||
|
"event-emitter": "^0.3.5",
|
||||||
|
"type": "^2.7.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/estree-walker": {
|
"node_modules/estree-walker": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/event-emitter": {
|
||||||
|
"version": "0.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
|
||||||
|
"integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"d": "1",
|
||||||
|
"es5-ext": "~0.10.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/exsolve": {
|
"node_modules/exsolve": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.8.tgz",
|
"resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.8.tgz",
|
||||||
@ -1934,6 +2295,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ext": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"type": "^2.7.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fast-diff": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz",
|
"resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||||
@ -2145,6 +2521,16 @@
|
|||||||
"he": "bin/he"
|
"he": "bin/he"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-void-elements": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/https-proxy-agent": {
|
"node_modules/https-proxy-agent": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||||
@ -2158,6 +2544,25 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/i18next": {
|
||||||
|
"version": "20.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-20.6.1.tgz",
|
||||||
|
"integrity": "sha512-yCMYTMEJ9ihCwEQQ3phLo7I/Pwycf8uAx+sRHwwk5U9Aui/IZYgQRyMqXafQOw5QQ7DM1Z+WyEXWIqSuJHhG2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "9.0.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
|
||||||
|
"integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/immutable": {
|
"node_modules/immutable": {
|
||||||
"version": "5.1.6",
|
"version": "5.1.6",
|
||||||
"resolved": "https://registry.npmmirror.com/immutable/-/immutable-5.1.6.tgz",
|
"resolved": "https://registry.npmmirror.com/immutable/-/immutable-5.1.6.tgz",
|
||||||
@ -2217,6 +2622,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-hotkey": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-number": {
|
"node_modules/is-number": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz",
|
||||||
@ -2227,6 +2638,21 @@
|
|||||||
"node": ">=0.12.0"
|
"node": ">=0.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-plain-object": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-url": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "9.0.1",
|
"version": "9.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||||
@ -2274,6 +2700,49 @@
|
|||||||
"lodash-es": "*"
|
"lodash-es": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.camelcase": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.clonedeep": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.debounce": {
|
||||||
|
"version": "4.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||||
|
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.foreach": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.foreach/-/lodash.foreach-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isequal": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||||
|
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.throttle": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.toarray": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.toarray/-/lodash.toarray-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-QyffEA3i5dma5q2490+SgCvDN0pXLmRGSyAANuVi0HQ01Pkfr9fuoKQW8wm1wGBnJITs/mS7wQvS6VshUEBFCw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@ -2344,6 +2813,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mime-match": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-match/-/mime-match-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"wildcard": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mime-types": {
|
"node_modules/mime-types": {
|
||||||
"version": "2.1.35",
|
"version": "2.1.35",
|
||||||
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
|
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
@ -2398,6 +2876,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/namespace-emitter": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/namespace-emitter/-/namespace-emitter-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.12",
|
"version": "3.3.12",
|
||||||
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz",
|
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz",
|
||||||
@ -2416,6 +2900,12 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/next-tick": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/node-addon-api": {
|
"node_modules/node-addon-api": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||||
@ -2452,6 +2942,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parchment": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/path-browserify": {
|
"node_modules/path-browserify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||||
@ -2554,6 +3050,33 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/preact": {
|
||||||
|
"version": "10.29.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz",
|
||||||
|
"integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"preact-render-to-string": ">=5"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"preact-render-to-string": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prismjs": {
|
||||||
|
"version": "1.30.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
|
||||||
|
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/proxy-from-env": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
@ -2616,6 +3139,35 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/quill": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-QfazNrhMakEdRG57IoYFwffUIr04LWJxbS/ZkidRFXYCQt63c1gK6Z7IHUXMx/Vh25WgPBU42oBaNzQ0K1R/xw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"parchment": "^3.0.0",
|
||||||
|
"quill-delta": "^5.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"npm": ">=8.2.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/quill-delta": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-diff": "^1.3.0",
|
||||||
|
"lodash.clonedeep": "^4.5.0",
|
||||||
|
"lodash.isequal": "^4.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.0.0.tgz",
|
||||||
@ -2759,6 +3311,15 @@
|
|||||||
"@parcel/watcher": "^2.4.1"
|
"@parcel/watcher": "^2.4.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/scroll-into-view-if-needed": {
|
||||||
|
"version": "2.2.31",
|
||||||
|
"resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
|
||||||
|
"integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"compute-scroll-into-view": "^1.0.20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/scule": {
|
"node_modules/scule": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz",
|
"resolved": "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz",
|
||||||
@ -2838,6 +3399,38 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/slate": {
|
||||||
|
"version": "0.72.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/slate/-/slate-0.72.8.tgz",
|
||||||
|
"integrity": "sha512-/nJwTswQgnRurpK+bGJFH1oM7naD5qDmHd89JyiKNT2oOKD8marW0QSBtuFnwEbL5aGCS8AmrhXQgNOsn4osAw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"immer": "^9.0.6",
|
||||||
|
"is-plain-object": "^5.0.0",
|
||||||
|
"tiny-warning": "^1.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/slate-history": {
|
||||||
|
"version": "0.66.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.66.0.tgz",
|
||||||
|
"integrity": "sha512-6MWpxGQZiMvSINlCbMW43E2YBSVMCMCIwQfBzGssjWw4kb0qfvj0pIdblWNRQZD0hR6WHP+dHHgGSeVdMWzfng==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-plain-object": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"slate": ">=0.65.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/snabbdom": {
|
||||||
|
"version": "3.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/snabbdom/-/snabbdom-3.6.4.tgz",
|
||||||
|
"integrity": "sha512-VmxEfuw1/Y/eFj5VtMhYnukExpYiPkNzoo3+N3qwAOUDMl8wXgbli5ebR+j0knE3lZ/0eYskLxNcX64uy10N9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.17.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@ -2847,6 +3440,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ssr-window": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssr-window/-/ssr-window-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/strip-literal": {
|
"node_modules/strip-literal": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-2.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-2.1.1.tgz",
|
||||||
@ -2873,6 +3472,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-warning": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
@ -2892,6 +3497,12 @@
|
|||||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
|
"node_modules/type": {
|
||||||
|
"version": "2.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",
|
||||||
|
"integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
|
||||||
@ -2913,6 +3524,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "8.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||||
|
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/unimport": {
|
"node_modules/unimport": {
|
||||||
"version": "3.14.6",
|
"version": "3.14.6",
|
||||||
"resolved": "https://registry.npmmirror.com/unimport/-/unimport-3.14.6.tgz",
|
"resolved": "https://registry.npmmirror.com/unimport/-/unimport-3.14.6.tgz",
|
||||||
@ -3344,6 +3962,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/wildcard": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wildcard/-/wildcard-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/zrender": {
|
"node_modules/zrender": {
|
||||||
"version": "5.6.1",
|
"version": "5.6.1",
|
||||||
"resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz",
|
"resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz",
|
||||||
|
|||||||
@ -11,6 +11,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.1",
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
|
"@vueup/vue-quill": "^1.5.5",
|
||||||
|
"@wangeditor/editor": "^5.1.23",
|
||||||
|
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"echarts": "^5.5.0",
|
"echarts": "^5.5.0",
|
||||||
@ -22,6 +25,7 @@
|
|||||||
"vue-router": "^4.3.0"
|
"vue-router": "^4.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^26.2.0",
|
||||||
"@types/qs": "^6.15.1",
|
"@types/qs": "^6.15.1",
|
||||||
"@vitejs/plugin-vue": "^5.0.4",
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
"sass": "^1.71.1",
|
"sass": "^1.71.1",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 437 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 304 KiB After Width: | Height: | Size: 437 KiB |
@ -57,7 +57,10 @@ function logout() {
|
|||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
function isActive(path: string): boolean {
|
function isActive(path: string): boolean {
|
||||||
return path === '/' ? route.path === '/' : route.path.startsWith(path)
|
if (path === '/') return route.path === '/'
|
||||||
|
// 严格匹配: route.path 完全等于 path, 或 route.path 是 path 的子路径 (path 后必须是 '/')
|
||||||
|
// 避免 '/user/enterprise/rating' 同时匹配 '/user/enterprise' (前缀误激活)
|
||||||
|
return route.path === path || route.path.startsWith(path + '/')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -292,7 +295,7 @@ onMounted(loadBadges)
|
|||||||
<aside class="rr-sidebar">
|
<aside class="rr-sidebar">
|
||||||
<!-- 品牌 + 搜索框(只在展开时显示) -->
|
<!-- 品牌 + 搜索框(只在展开时显示) -->
|
||||||
<div class="rr-sidebar-brand" v-if="!collapsed">
|
<div class="rr-sidebar-brand" v-if="!collapsed">
|
||||||
<img class="rr-sidebar-logo" :src="logoSrc" alt="巨变" />
|
<img class="rr-sidebar-logo" :src="logoSrc" alt="Logo" />
|
||||||
<span class="rr-sidebar-name">产业互联网平台运营后台</span>
|
<span class="rr-sidebar-name">产业互联网平台运营后台</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -63,7 +63,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
pageSizes: () => [10, 20, 50, 100],
|
pageSizes: () => [10, 20, 50, 100],
|
||||||
rowKey: 'id',
|
rowKey: 'id',
|
||||||
stripe: false,
|
stripe: false,
|
||||||
borderless: false,
|
// 2026-08-18 UI 统一: 默认无边框 (带 border 的老页面由全局样式消除, 见 styles/global-ui.scss)
|
||||||
|
borderless: true,
|
||||||
emptyText: '暂无数据',
|
emptyText: '暂无数据',
|
||||||
errorText: '',
|
errorText: '',
|
||||||
error: null
|
error: null
|
||||||
|
|||||||
266
src/components/RichEditor/RichEditor.vue
Normal file
266
src/components/RichEditor/RichEditor.vue
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
<template>
|
||||||
|
<div class="rich-editor">
|
||||||
|
<Toolbar class="rich-editor__toolbar" :editor="editorRef" :defaultConfig="toolbarConfig" mode="default" />
|
||||||
|
<Editor
|
||||||
|
class="rich-editor__body"
|
||||||
|
v-model="model"
|
||||||
|
:defaultConfig="editorConfig"
|
||||||
|
mode="default"
|
||||||
|
@onCreated="handleCreated"
|
||||||
|
@onChange="handleChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, shallowRef, onBeforeUnmount, watch } from "vue"
|
||||||
|
import { Editor, Toolbar } from "@wangeditor/editor-for-vue"
|
||||||
|
import "@wangeditor/editor/dist/css/style.css"
|
||||||
|
|
||||||
|
const model = defineModel<string>({ default: "" })
|
||||||
|
|
||||||
|
const editorRef = shallowRef<any>(null)
|
||||||
|
const valueHtml = ref(model.value || "")
|
||||||
|
// 粘贴保留的原始 HTML (公众号排版完整版); 用户手动编辑后清空
|
||||||
|
let rawPasteHtml = ""
|
||||||
|
// 标记是否为程序 setHtml (避免 onChange 清掉 rawPasteHtml)
|
||||||
|
let programmaticSet = false
|
||||||
|
|
||||||
|
// === 单文件上传 (admin_new: /api 前缀 + access_token, 5MB) ===
|
||||||
|
async function uploadFile(file: File): Promise<string> {
|
||||||
|
if (file.size > 5 * 1024 * 1024) throw new Error("图片大小不能超过5MB,请压缩后重试")
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append("file", file)
|
||||||
|
const token = localStorage.getItem("access_token") || ""
|
||||||
|
let resp: Response
|
||||||
|
try {
|
||||||
|
resp = await fetch("/api/sys/file/upload", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: fd,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
throw new Error("图片上传失败,请检查网络后重试")
|
||||||
|
}
|
||||||
|
if (resp.status === 413) throw new Error("图片超过大小限制(5MB),请压缩后重试")
|
||||||
|
const data = await resp.json()
|
||||||
|
if (data.code === 0) return data?.data?.url || data?.url || ""
|
||||||
|
throw new Error(data.msg || "图片上传失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 外链图片转存: 微信等 CDN 有防盗链且无 CORS 头, 前端 fetch 必败 —— 走后端服务端拉取转存 ===
|
||||||
|
async function remoteToLocal(src: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("access_token") || ""
|
||||||
|
const resp = await fetch("/api/sys/file/uploadByUrl", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: token ? `Bearer ${token}` : "" },
|
||||||
|
body: JSON.stringify({ url: src }),
|
||||||
|
})
|
||||||
|
const data = await resp.json()
|
||||||
|
if (data.code === 0 && data.data?.url) return data.data.url
|
||||||
|
console.warn("图片转存失败:", src, data.msg)
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn("图片转存异常:", src, e?.message)
|
||||||
|
}
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 处理粘贴 HTML: 转存外链图 -> 清洗危险/不兼容标签 -> 返回 [原始版, 编辑器版] ===
|
||||||
|
async function processPasteHtml(html: string): Promise<{ raw: string; editorSafe: string }> {
|
||||||
|
const imgRe = /<img[^>]+src=["\x27]([^"\x27]+)["\x27][^>]*>/gi
|
||||||
|
const srcs: string[] = []
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
while ((m = imgRe.exec(html)) !== null) {
|
||||||
|
const src = m[1]
|
||||||
|
if (!srcs.includes(src)) srcs.push(src)
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw = html
|
||||||
|
let editorSafe = html
|
||||||
|
for (const src of srcs) {
|
||||||
|
if (!/^https?:\/\//.test(src)) continue
|
||||||
|
if (src.includes("/uploads/") || src.includes(location.hostname)) continue
|
||||||
|
const local = await remoteToLocal(src)
|
||||||
|
if (local !== src) {
|
||||||
|
raw = raw.split(src).join(local)
|
||||||
|
editorSafe = editorSafe.split(src).join(local)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑器版: 移除 section/iframe/script/style/svg/自定义标签(mp-xxx 等)/class —— 这些会导致
|
||||||
|
// wangeditor(slate) HTML 解析丢节点(用户反馈"部分文本未粘贴成功"的根因), 保留文本与常规排版
|
||||||
|
editorSafe = editorSafe
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
||||||
|
.replace(/<iframe[\s\S]*?<\/iframe>/gi, "")
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
||||||
|
.replace(/<svg[\s\S]*?<\/svg>/gi, "")
|
||||||
|
.replace(/<section\b/gi, "<div")
|
||||||
|
.replace(/<\/section>/gi, "</div>")
|
||||||
|
// 公众号自定义标签 (mp-common-*, mpprofile 等) 整块移除
|
||||||
|
.replace(/<mp-[a-z-]+[\s\S]*?<\/mp-[a-z-]+>/gi, "")
|
||||||
|
.replace(/<\/?mp-[a-z-]+[^>]*>/gi, "")
|
||||||
|
// 其余非 HTML 标准标签转为 div 保留内部文本
|
||||||
|
.replace(/<([a-z][a-z0-9-]*)((?:\s[^>]*)?)>/gi, (m0, tag, attrs) => {
|
||||||
|
if (/^(div|p|span|br|img|strong|b|em|i|u|s|ul|ol|li|h[1-6]|blockquote|table|thead|tbody|tr|td|th|a|hr|pre|code|font|center|section)$/i.test(tag)) return m0
|
||||||
|
return "<div" + attrs + ">"
|
||||||
|
})
|
||||||
|
.replace(/<\/([a-z][a-z0-9-]*)>/gi, (m0, tag) => {
|
||||||
|
if (/^(div|p|span|br|img|strong|b|em|i|u|s|ul|ol|li|h[1-6]|blockquote|table|thead|tbody|tr|td|th|a|hr|pre|code|font|center|section)$/i.test(tag)) return m0
|
||||||
|
return "</div>"
|
||||||
|
})
|
||||||
|
.replace(/\sclass=["\x27][^"\x27]*["\x27]/gi, "")
|
||||||
|
.replace(/<br\s*\/?\s*>/gi, "<br>")
|
||||||
|
|
||||||
|
return { raw, editorSafe }
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 图片上传配置 (工具栏选择/拖拽) ===
|
||||||
|
const editorConfig = {
|
||||||
|
placeholder: "请输入资讯内容…支持直接粘贴公众号图文(保留排版,图片自动转存平台)",
|
||||||
|
MENU_CONF: {
|
||||||
|
uploadImage: {
|
||||||
|
maxFileSize: 5 * 1024 * 1024,
|
||||||
|
async customUpload(file: File, insertFn: (url: string, alt: string, href: string) => void) {
|
||||||
|
try {
|
||||||
|
const url = await uploadFile(file)
|
||||||
|
insertFn(url, "", "")
|
||||||
|
} catch (e: any) {
|
||||||
|
alert(e?.message || "图片上传失败")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 自定义粘贴: ① HTML -> 转存外链图 + 保留原文(rawPasteHtml) + 插入清洗版(编辑器不崩) ② 位图 -> 上传插入
|
||||||
|
customPaste: (editor: any, event: ClipboardEvent, callback: (v: boolean) => void) => {
|
||||||
|
const cd = event.clipboardData
|
||||||
|
if (!cd) return callback(true)
|
||||||
|
|
||||||
|
const html = cd.getData("text/html")
|
||||||
|
const items = cd.items
|
||||||
|
const imageFiles: File[] = []
|
||||||
|
if (items) {
|
||||||
|
for (const it of Array.from(items)) {
|
||||||
|
if (it.type.startsWith("image/")) {
|
||||||
|
const f = it.getAsFile()
|
||||||
|
if (f) imageFiles.push(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (html) {
|
||||||
|
event.preventDefault()
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const { raw, editorSafe } = await processPasteHtml(html)
|
||||||
|
// 保存原始 HTML: 提交时用 (公众号排版完整保留)
|
||||||
|
rawPasteHtml = raw
|
||||||
|
programmaticSet = true
|
||||||
|
try {
|
||||||
|
editor.dangerouslyInsertHtml(editorSafe)
|
||||||
|
} catch (insertErr: any) {
|
||||||
|
// slate 解析失败兜底: 插入纯文本, 保证文本内容不丢
|
||||||
|
console.error("insert html failed, fallback to text:", insertErr)
|
||||||
|
const text = editorSafe.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim()
|
||||||
|
editor.insertText(text)
|
||||||
|
}
|
||||||
|
setTimeout(() => { programmaticSet = false }, 100)
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("paste html error:", e)
|
||||||
|
event.preventDefault()
|
||||||
|
editor.dangerouslyInsertHtml(html)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
callback(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageFiles.length) {
|
||||||
|
event.preventDefault()
|
||||||
|
;(async () => {
|
||||||
|
for (const f of imageFiles) {
|
||||||
|
try {
|
||||||
|
const url = await uploadFile(f)
|
||||||
|
rawPasteHtml = ""
|
||||||
|
editor.insertNode({ type: "image", src: url, alt: "", href: "" })
|
||||||
|
editor.insertNode({ type: "paragraph", children: [{ text: "" }] })
|
||||||
|
} catch (e: any) {
|
||||||
|
alert(e?.message || "图片上传失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
callback(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(true)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolbarConfig = {
|
||||||
|
excludeKeys: ["group-video", "fullScreen"],
|
||||||
|
toolbarKeys: [
|
||||||
|
"headerSelect", "bold", "italic", "underline", "through",
|
||||||
|
"color", "bgColor", "fontSize", "fontFamily", "lineHeight",
|
||||||
|
"justifyLeft", "justifyCenter", "justifyRight",
|
||||||
|
"bulletedList", "numberedList", "blockquote", "codeBlock",
|
||||||
|
"insertLink", "uploadImage", "insertTable", "undo", "redo",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreated(editor: any) {
|
||||||
|
editorRef.value = editor
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(editor: any) {
|
||||||
|
valueHtml.value = editor.getHtml()
|
||||||
|
model.value = valueHtml.value
|
||||||
|
// 用户手动编辑 (非程序 setHtml) -> 放弃 rawPasteHtml, 以编辑器内容为准
|
||||||
|
if (!programmaticSet) {
|
||||||
|
rawPasteHtml = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 外部 v-model 变化时同步 (编辑回显)
|
||||||
|
watch(model, (v) => {
|
||||||
|
if (v !== valueHtml.value && editorRef.value) {
|
||||||
|
programmaticSet = true
|
||||||
|
editorRef.value.setHtml(v || "")
|
||||||
|
setTimeout(() => { programmaticSet = false }, 100)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// === 对外: 获取最终内容 (粘贴原文优先, 编辑器修改后以编辑器为准) ===
|
||||||
|
function getContent(): string {
|
||||||
|
if (rawPasteHtml) return rawPasteHtml
|
||||||
|
return editorRef.value ? editorRef.value.getHtml() : valueHtml.value
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ getContent })
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (editorRef.value) editorRef.value.destroy()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rich-editor {
|
||||||
|
border: 1px solid var(--el-border-color, #dcdfe6);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.rich-editor__toolbar {
|
||||||
|
/* 工具栏吸顶: 页面滚动编辑长内容时始终固定在可视区顶部 (2026-09-21) */
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: var(--el-bg-color, #fff);
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
border-bottom: 1px solid var(--el-border-color, #dcdfe6);
|
||||||
|
}
|
||||||
|
.rich-editor__body {
|
||||||
|
min-height: 320px;
|
||||||
|
}
|
||||||
|
.rich-editor__body :deep(.w-e-text-container) {
|
||||||
|
min-height: 320px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -74,6 +74,10 @@ export const MENU_LABELS: MenuLabel[] = [
|
|||||||
{ url: 'order/serviceorder-commission', label: '服务订单分佣统计', icon: 'Coin', path: '/order/serviceorder/commission', sort: 221 },
|
{ url: 'order/serviceorder-commission', label: '服务订单分佣统计', icon: 'Coin', path: '/order/serviceorder/commission', sort: 221 },
|
||||||
{ url: 'order/orderbase-price-analysis', label: '基础价格分析表', icon: 'TrendCharts', path: '/order/base-price-analysis', sort: 230 },
|
{ url: 'order/orderbase-price-analysis', label: '基础价格分析表', icon: 'TrendCharts', path: '/order/base-price-analysis', sort: 230 },
|
||||||
{ url: 'order/orderpay', label: '订单支付回调', icon: 'Wallet', path: '/order/orderpay', sort: 240 },
|
{ url: 'order/orderpay', label: '订单支付回调', icon: 'Wallet', path: '/order/orderpay', sort: 240 },
|
||||||
|
{ url: 'order/servicefee', label: '平台服务费管理', icon: 'Money', path: '/order/servicefee', sort: 245 },
|
||||||
|
{ url: 'order/backfill-manage', label: '补录管理', icon: 'DocumentAdd', path: '/order/backfill-manage', sort: 246 },
|
||||||
|
{ url: 'user/enterprise-score', label: '企业评分与等级', icon: 'Trophy', path: '/user/enterprise-score', sort: 150 },
|
||||||
|
{ url: 'sys/ai-manage', label: 'AI 能力管理', icon: 'Cpu', path: '/sys/ai-manage', sort: 155 },
|
||||||
{ url: 'order/refund', label: '订单退款管理', icon: 'Refresh', path: '/order/refund', sort: 250 },
|
{ url: 'order/refund', label: '订单退款管理', icon: 'Refresh', path: '/order/refund', sort: 250 },
|
||||||
{ url: 'order/aftersale', label: '售后订单', icon: 'Service', path: '/order/aftersale', sort: 260 },
|
{ url: 'order/aftersale', label: '售后订单', icon: 'Service', path: '/order/aftersale', sort: 260 },
|
||||||
|
|
||||||
@ -116,6 +120,7 @@ export const MENU_LABELS: MenuLabel[] = [
|
|||||||
{ url: 'other/help', label: '帮助中心', icon: 'QuestionFilled', path: '/other/help', sort: 701 },
|
{ url: 'other/help', label: '帮助中心', icon: 'QuestionFilled', path: '/other/help', sort: 701 },
|
||||||
{ url: 'other/operateguide', label: '内容列表', icon: 'Reading', path: '/other/guide', sort: 702 },
|
{ url: 'other/operateguide', label: '内容列表', icon: 'Reading', path: '/other/guide', sort: 702 },
|
||||||
{ url: 'other/industry', label: '行业', icon: 'OfficeBuilding', path: '/other/industry', sort: 703 },
|
{ url: 'other/industry', label: '行业', icon: 'OfficeBuilding', path: '/other/industry', sort: 703 },
|
||||||
|
{ url: 'other/label', label: '标签管理', icon: 'CollectionTag', path: '/other/label', sort: 704 },
|
||||||
// other/label 已隐藏 (后端无表无 controller)
|
// other/label 已隐藏 (后端无表无 controller)
|
||||||
{ url: 'other/categoryGood', label: '平台分类', icon: 'Menu', path: '/other/category/good', sort: 705 },
|
{ url: 'other/categoryGood', label: '平台分类', icon: 'Menu', path: '/other/category/good', sort: 705 },
|
||||||
{ url: 'other/categoryZX', label: '资讯分类', icon: 'Menu', path: '/other/category/zx', sort: 707 },
|
{ url: 'other/categoryZX', label: '资讯分类', icon: 'Menu', path: '/other/category/zx', sort: 707 },
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import 'element-plus/dist/index.css'
|
|||||||
import './theme/tokens.scss'
|
import './theme/tokens.scss'
|
||||||
import './theme/themes.scss'
|
import './theme/themes.scss'
|
||||||
import './styles/global-popper.scss'
|
import './styles/global-popper.scss'
|
||||||
|
import './styles/global-ui.scss' // 2026-08-18 UI 统一规范 (表格/卡片/内边距/暗色状态色)
|
||||||
import { loadTheme } from './theme/presets'
|
import { loadTheme } from './theme/presets'
|
||||||
|
|
||||||
// 启动时恢复用户主题
|
// 启动时恢复用户主题
|
||||||
|
|||||||
@ -131,7 +131,7 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 预览弹窗 -->
|
<!-- 预览弹窗 -->
|
||||||
<el-dialog v-model="previewVisible" title="反馈内容" width="700px">
|
<el-dialog v-model="previewVisible" title="反馈内容" width="800px">
|
||||||
<el-form label-width="100px" v-if="previewData">
|
<el-form label-width="100px" v-if="previewData">
|
||||||
<el-form-item label="用户昵称">
|
<el-form-item label="用户昵称">
|
||||||
<el-input :model-value="previewData.nickName" readonly />
|
<el-input :model-value="previewData.nickName" readonly />
|
||||||
@ -150,7 +150,7 @@ onMounted(load)
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="反馈图片" v-if="previewData.imgList && previewData.imgList.length">
|
<el-form-item label="反馈图片" v-if="previewData.imgList && previewData.imgList.length">
|
||||||
<div class="feedback-imgs">
|
<div class="feedback-imgs">
|
||||||
<el-image v-for="(img, idx) in previewData.imgList" :key="idx" :src="img" :preview-src-list="previewData.imgList" :initial-index="idx" fit="cover" class="feedback-img-item" />
|
<el-image v-for="(img, idx) in previewData.imgList" :key="idx" :src="img" :preview-src-list="previewData.imgList" :initial-index="idx" fit="cover" class="feedback-img-item" preview-teleported />
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|||||||
@ -134,7 +134,7 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 增改弹窗 -->
|
<!-- 增改弹窗 -->
|
||||||
<el-dialog v-model="editVisible" :title="editMode === 'add' ? '新增帮助' : '修改帮助'" width="560px">
|
<el-dialog v-model="editVisible" :title="editMode === 'add' ? '新增帮助' : '修改帮助'" width="640px">
|
||||||
<el-form label-width="80px">
|
<el-form label-width="80px">
|
||||||
<el-form-item label="标题" required>
|
<el-form-item label="标题" required>
|
||||||
<el-input v-model="editForm.title" placeholder="请输入标题" maxlength="50" show-word-limit />
|
<el-input v-model="editForm.title" placeholder="请输入标题" maxlength="50" show-word-limit />
|
||||||
|
|||||||
@ -149,7 +149,7 @@ onMounted(load)
|
|||||||
<el-tag :type="(TYPE_MAP[row.type]?.type as any)" size="small" effect="light">{{ TYPE_MAP[row.type]?.label || `类型${row.type}` }}</el-tag>
|
<el-tag :type="(TYPE_MAP[row.type]?.type as any)" size="small" effect="light">{{ TYPE_MAP[row.type]?.label || `类型${row.type}` }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
<template #image="{ row }">
|
<template #image="{ row }">
|
||||||
<el-image :src="row.image" fit="cover" class="lunbo-thumb" :preview-src-list="[row.image]" />
|
<el-image :src="row.image" fit="cover" class="lunbo-thumb" :preview-src-list="[row.image]" preview-teleported />
|
||||||
</template>
|
</template>
|
||||||
<template #position="{ row }">
|
<template #position="{ row }">
|
||||||
<el-tag :type="(POS_MAP[row.position]?.type as any)" size="small" effect="plain">{{ POS_MAP[row.position]?.label || `位置${row.position}` }}</el-tag>
|
<el-tag :type="(POS_MAP[row.position]?.type as any)" size="small" effect="plain">{{ POS_MAP[row.position]?.label || `位置${row.position}` }}</el-tag>
|
||||||
@ -161,7 +161,7 @@ onMounted(load)
|
|||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-dialog v-model="editVisible" :title="editMode === 'add' ? '新增轮播' : '修改轮播'" width="560px">
|
<el-dialog v-model="editVisible" :title="editMode === 'add' ? '新增轮播' : '修改轮播'" width="640px">
|
||||||
<el-form label-width="100px">
|
<el-form label-width="100px">
|
||||||
<el-form-item label="标题" required>
|
<el-form-item label="标题" required>
|
||||||
<el-input v-model="editForm.title" placeholder="请输入标题" maxlength="30" show-word-limit />
|
<el-input v-model="editForm.title" placeholder="请输入标题" maxlength="30" show-word-limit />
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import qs from 'qs'
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
import { View, Hide, Lock } from '@element-plus/icons-vue'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import logoUrl from '@/assets/logo-jubian.png'
|
import logoUrl from '@/assets/logo-jubian.png'
|
||||||
import logoLightUrl from '@/assets/logo-jubian-light.png'
|
import logoLightUrl from '@/assets/logo-jubian-light.png'
|
||||||
@ -27,6 +28,7 @@ const userStore = useUserStore()
|
|||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
const logoSrc = computed(() => themeStore.current === 'dark' ? logoLightUrl : logoUrl)
|
const logoSrc = computed(() => themeStore.current === 'dark' ? logoLightUrl : logoUrl)
|
||||||
|
|
||||||
|
const showPwd = ref(false)
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
username: 'admin',
|
username: 'admin',
|
||||||
password: 'admin',
|
password: 'admin',
|
||||||
@ -102,7 +104,7 @@ async function onLogin() {
|
|||||||
<!-- 左 65%: 品牌区 -->
|
<!-- 左 65%: 品牌区 -->
|
||||||
<div class="rr-login-left">
|
<div class="rr-login-left">
|
||||||
<div class="rr-login-brand">
|
<div class="rr-login-brand">
|
||||||
<img class="rr-login-logo" :src="logoSrc" alt="巨变" />
|
<img class="rr-login-logo" :src="logoSrc" alt="Logo" />
|
||||||
<div class="rr-login-product-name">产业互联网平台运营后台</div>
|
<div class="rr-login-product-name">产业互联网平台运营后台</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -150,7 +152,8 @@ async function onLogin() {
|
|||||||
<label class="rr-field-label">密码</label>
|
<label class="rr-field-label">密码</label>
|
||||||
<div class="rr-input-wrap">
|
<div class="rr-input-wrap">
|
||||||
<el-icon class="rr-input-icon"><Lock /></el-icon>
|
<el-icon class="rr-input-icon"><Lock /></el-icon>
|
||||||
<input v-model="form.password" type="password" class="rr-input" placeholder="请输入密码" autocomplete="current-password" />
|
<input v-model="form.password" :type="showPwd ? 'text' : 'password'" class="rr-input has-eye" placeholder="请输入密码" autocomplete="current-password" />
|
||||||
|
<el-icon class="rr-input-eye" @click="showPwd = !showPwd" :title="showPwd ? '隐藏密码' : '查看密码'"><View v-if="showPwd" /><Hide v-else /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -211,7 +214,7 @@ async function onLogin() {
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 舞台(套设计稿 .login-stage) */
|
/* 舞台(套设计稿 .login-stage) — 背景透明,logo/表单直接渲染在 .rr-login 页面渐变上 */
|
||||||
.rr-login-stage {
|
.rr-login-stage {
|
||||||
width: 1324px;
|
width: 1324px;
|
||||||
height: 600px;
|
height: 600px;
|
||||||
@ -221,26 +224,11 @@ async function onLogin() {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
position: relative;
|
position: relative;
|
||||||
background: linear-gradient(145deg, var(--login-grad-1) 0%, var(--login-grad-2) 50%, var(--login-grad-3) 100%);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
box-shadow: 0 20px 50px -12px rgba(15, 23, 42, 0.1);
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background-image:
|
|
||||||
linear-gradient(var(--brand) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, var(--brand) 1px, transparent 1px);
|
|
||||||
background-size: 32px 32px;
|
|
||||||
opacity: 0.05;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
& > * { position: relative; z-index: 1; }
|
& > * { position: relative; z-index: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 左 65% 品牌区 */
|
/* 左 65% 品牌区(背景透明,让 logo/品牌文字直接渲染在 .rr-login 页面渐变上) */
|
||||||
.rr-login-left {
|
.rr-login-left {
|
||||||
flex: 1.857;
|
flex: 1.857;
|
||||||
padding: 48px;
|
padding: 48px;
|
||||||
@ -248,42 +236,16 @@ async function onLogin() {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
position: relative;
|
position: relative;
|
||||||
background: linear-gradient(135deg, var(--login-grad-1) 0%, var(--login-grad-2) 50%, var(--login-grad-3) 100%);
|
background: transparent;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
&::before {
|
/* 之前的 ::before 蓝紫光晕 + ::after 网格装饰, 在 left 透明后会让品牌区看起来像个发光方框, 故移除 */
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background-image:
|
|
||||||
radial-gradient(ellipse 800px 500px at 25% 30%, var(--login-glow-1) 0%, transparent 50%),
|
|
||||||
radial-gradient(ellipse 700px 400px at 75% 70%, var(--login-glow-2) 0%, transparent 50%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
&::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background-image:
|
|
||||||
linear-gradient(var(--brand) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, var(--brand) 1px, transparent 1px);
|
|
||||||
background-size: 32px 32px;
|
|
||||||
opacity: 0.04;
|
|
||||||
mask-image: radial-gradient(ellipse at center, black 20%, transparent 70%);
|
|
||||||
-webkit-mask-image: radial-gradient(ellipse at center, black 20%, transparent 70%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
& > * { position: relative; z-index: 1; }
|
|
||||||
}
|
}
|
||||||
.rr-login-brand { display: flex; align-items: center; gap: 10px; }
|
.rr-login-brand { display: flex; align-items: center; gap: 10px; }
|
||||||
.rr-login-logo {
|
.rr-login-logo {
|
||||||
width: 36px; height: 36px;
|
width: 48px; height: 48px;
|
||||||
border-radius: 9px;
|
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
padding: 2px;
|
|
||||||
box-shadow: 0 6px 16px var(--brand-glow);
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
}
|
||||||
.rr-login-product-name { font-size: 16px; font-weight: 600; color: var(--ink); }
|
.rr-login-product-name { font-size: 16px; font-weight: 600; color: var(--ink); }
|
||||||
.rr-login-display { max-width: 460px; }
|
.rr-login-display { max-width: 460px; }
|
||||||
@ -353,6 +315,20 @@ async function onLogin() {
|
|||||||
color: var(--ink-3);
|
color: var(--ink-3);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
.rr-input-eye {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px; top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
&:hover { color: var(--brand); }
|
||||||
|
}
|
||||||
|
.rr-input.has-eye {
|
||||||
|
padding-right: 36px;
|
||||||
|
}
|
||||||
.rr-input {
|
.rr-input {
|
||||||
width: 100%; height: 38px;
|
width: 100%; height: 38px;
|
||||||
padding: 0 12px 0 34px;
|
padding: 0 12px 0 34px;
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
import { ref, onMounted, watch, nextTick } from 'vue'
|
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||||
import { httpGet } from '@/utils/request'
|
import { httpGet } from '@/utils/request'
|
||||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||||
|
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||||
|
|
||||||
interface Stat {
|
interface Stat {
|
||||||
label: string
|
label: string
|
||||||
@ -343,23 +344,14 @@ window.addEventListener('resize', () => { trendChart?.resize(); statusChart?.res
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="rr-dashboard">
|
<div class="rr-dashboard">
|
||||||
<div class="rr-page-header">
|
<PageHeader title="数据看板" :sub="`2026 年 6 月 17 日 · 实时数据`">
|
||||||
<div>
|
<el-tag
|
||||||
<h2 class="rr-page-title">数据看板</h2>
|
v-if="apiStatus !== 'loading'"
|
||||||
<p class="rr-page-sub">
|
size="small"
|
||||||
2026 年 6 月 17 日 · 实时数据
|
:type="apiStatus === 'live' ? 'success' : 'info'"
|
||||||
<el-tag
|
effect="light"
|
||||||
v-if="apiStatus !== 'loading'"
|
>{{ apiStatus === 'live' ? '实时' : '示例数据' }}</el-tag>
|
||||||
size="small"
|
</PageHeader>
|
||||||
:type="apiStatus === 'live' ? 'success' : 'info'"
|
|
||||||
effect="light"
|
|
||||||
class="rr-api-badge"
|
|
||||||
>
|
|
||||||
{{ apiStatus === 'live' ? '实时' : '示例数据' }}
|
|
||||||
</el-tag>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 4 个统计卡 -->
|
<!-- 4 个统计卡 -->
|
||||||
<div class="rr-stats-grid">
|
<div class="rr-stats-grid">
|
||||||
@ -411,14 +403,8 @@ window.addEventListener('resize', () => { trendChart?.resize(); statusChart?.res
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.rr-dashboard { max-width: 1400px; }
|
.rr-dashboard { width: 100%; }
|
||||||
.rr-page-header { margin-bottom: 20px; }
|
/* header 已抽到 PageHeader 组件, 此处不再维护 */
|
||||||
.rr-page-title {
|
|
||||||
font-size: 22px; font-weight: 600; letter-spacing: -0.02em;
|
|
||||||
color: var(--ink); margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.rr-page-sub { color: var(--ink-3); font-size: 13px; }
|
|
||||||
.rr-api-badge { margin-left: 8px; }
|
|
||||||
|
|
||||||
.rr-stats-grid {
|
.rr-stats-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@ -11,8 +11,9 @@
|
|||||||
import { ref, onMounted, watch, computed } from 'vue'
|
import { ref, onMounted, watch, computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { httpGet } from '@/utils/request'
|
import { httpGet, httpPost } from '@/utils/request'
|
||||||
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||||
|
import { ArrowRight } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
interface Inquiry {
|
interface Inquiry {
|
||||||
id: string
|
id: string
|
||||||
@ -32,6 +33,16 @@ interface Inquiry {
|
|||||||
fileList: { url: string; imagesName: string }[]
|
fileList: { url: string; imagesName: string }[]
|
||||||
// 报价列表
|
// 报价列表
|
||||||
quotationList: Quotation[]
|
quotationList: Quotation[]
|
||||||
|
// 新结构(与发布询价信息一致)
|
||||||
|
procurementTypeName?: string
|
||||||
|
quotationTypeName?: string
|
||||||
|
quotationDemandName?: string
|
||||||
|
paymentMethodName?: string
|
||||||
|
invoiceTypeName?: string
|
||||||
|
inquiryValidDate?: string
|
||||||
|
publishDate?: string
|
||||||
|
note?: string
|
||||||
|
detailList?: any[]
|
||||||
raw: any
|
raw: any
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,29 +104,55 @@ const detail = ref<Inquiry | null>(null)
|
|||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const inquiryId = computed(() => String(route.params.id || ''))
|
const inquiryId = computed(() => String(route.params.id || ''))
|
||||||
|
|
||||||
|
const PROC_TYPE: Record<number, string> = { 1: '定量采购', 2: '框架采购' }
|
||||||
|
const DEMAND: Record<number, string> = { 1: '不允许部分报价', 2: '允许部分报价' }
|
||||||
|
// 发票要求展示 (2026-10-16 票种化)
|
||||||
|
function invoiceTypeLabel(v: any): string {
|
||||||
|
const n = Number(v)
|
||||||
|
if (v === null || v === undefined || v === '' || isNaN(n)) return '—'
|
||||||
|
if (n === 0) return '无'
|
||||||
|
if (n === 2) return '增值税专用发票'
|
||||||
|
if (n >= 200) return `增值税专用发票 ${n - 200}%`
|
||||||
|
if (n >= 100) return `增值税普通发票 ${n - 100}%`
|
||||||
|
if ([1, 3, 6, 9, 13].includes(n)) return `${n}% 发票`
|
||||||
|
return String(v)
|
||||||
|
}
|
||||||
|
const QUOTE_TYPE: Record<number, string> = { 1: '公开报价', 2: '邀请报价' }
|
||||||
|
const mapLabel = (m: Record<number, string>, v: any) => (v == null ? '--' : m[Number(v)] || String(v))
|
||||||
|
const mapLabel1 = (m: Record<number, string>, v: any) => (v == null ? '--' : m[Number(v)] || String(v))
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const resp: any = await httpGet('/supplier/inquiry/getById', { id: inquiryId.value })
|
const resp: any = await httpGet('/supplier/inquiry/getById', { id: inquiryId.value })
|
||||||
if (resp && resp.id) {
|
if (resp && resp.id) {
|
||||||
|
const first = resp.inquiryDetailList?.[0] || {}
|
||||||
detail.value = {
|
detail.value = {
|
||||||
...FALLBACK,
|
...FALLBACK,
|
||||||
...resp,
|
...resp,
|
||||||
id: String(resp.id || inquiryId.value),
|
id: String(resp.id || inquiryId.value),
|
||||||
title: resp.title || FALLBACK.title,
|
title: resp.title || FALLBACK.title,
|
||||||
productName: resp.productName || resp.product || FALLBACK.productName,
|
productName: first.productName || '--',
|
||||||
productBrand: resp.brand || resp.productBrand || FALLBACK.productBrand,
|
productBrand: first.productBrand || '--',
|
||||||
specification: resp.specification || resp.spec || FALLBACK.specification,
|
specification: first.specification || '--',
|
||||||
unit: resp.unit || FALLBACK.unit,
|
unit: first.unit || '--',
|
||||||
quantity: resp.purchaseQuantity || resp.quantity || FALLBACK.quantity,
|
quantity: resp.purchaseQuantity ?? '--',
|
||||||
shippingAddress: resp.shippingAddress || resp.address || FALLBACK.shippingAddress,
|
// 新结构字段:与发布询价信息一致
|
||||||
deliveryDate: resp.deliveryDate || FALLBACK.deliveryDate,
|
procurementTypeName: mapLabel(PROC_TYPE, resp.procurementType),
|
||||||
priceValidDate: resp.priceValidDate || FALLBACK.priceValidDate,
|
quotationTypeName: mapLabel(QUOTE_TYPE, resp.quotationType),
|
||||||
enterpriseName: resp.inquiryEnterpriseName || resp.enterpriseName || FALLBACK.enterpriseName,
|
quotationDemandName: mapLabel(DEMAND, resp.quotationDemand),
|
||||||
fileList: resp.fileList || FALLBACK.fileList,
|
paymentMethodName: resp.paymentMethodName || '--',
|
||||||
quotationList: resp.quotationList || FALLBACK.quotationList,
|
invoiceTypeName: invoiceTypeLabel(resp.invoiceType),
|
||||||
|
inquiryValidDate: resp.inquiryValidDate || '--',
|
||||||
|
note: resp.note || '',
|
||||||
|
detailNote: '', // 清除 FALLBACK 占位文案,备注一律取真实数据
|
||||||
|
enterpriseName: resp.inquiryParty || resp.inquiryEnterpriseName || FALLBACK.enterpriseName,
|
||||||
|
detailList: resp.inquiryDetailList || [],
|
||||||
|
fileList: resetFiles(resp),
|
||||||
raw: resp,
|
raw: resp,
|
||||||
}
|
}
|
||||||
|
// 报价回复:受邀供应商 + 已报价企业商品清单
|
||||||
|
await Promise.all([loadInvited(resp.orderId || resp.id), loadQuotes(resp.orderId || resp.id)])
|
||||||
} else {
|
} else {
|
||||||
detail.value = { ...FALLBACK, id: inquiryId.value }
|
detail.value = { ...FALLBACK, id: inquiryId.value }
|
||||||
}
|
}
|
||||||
@ -126,6 +163,134 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 附件:兼容两种返回形态——① images=URL 字符串数组 + imageNames ② images=[{url, imagesName}] 对象数组
|
||||||
|
function resetFiles(resp: any): any[] {
|
||||||
|
const raw: any[] = resp.images || []
|
||||||
|
const names: string[] = Array.isArray(resp.imageNames) ? resp.imageNames : String(resp.imageNames || '').split(',')
|
||||||
|
const out: any[] = []
|
||||||
|
for (let i = 0; i < raw.length; i++) {
|
||||||
|
const item = raw[i]
|
||||||
|
if (item && typeof item === 'object') {
|
||||||
|
if (!item.url) continue
|
||||||
|
out.push({ url: String(item.url), imagesName: item.imagesName || names[i] || ('附件' + (i + 1)) })
|
||||||
|
} else if (typeof item === 'string' && item.trim()) {
|
||||||
|
out.push({ url: item.trim(), imagesName: names[i] || ('附件' + (i + 1)) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const quoteGroups = ref<any[]>([])
|
||||||
|
const quoteLoading = ref(false)
|
||||||
|
// 报价列表卡片展开索引(-1=全部收起)
|
||||||
|
const openSupIndex = ref(-1)
|
||||||
|
function toggleSup(i: number) { openSupIndex.value = openSupIndex.value === i ? -1 : i }
|
||||||
|
|
||||||
|
// 供应商维度报价列表:受邀供应商(含报价状态)+ 主动报价供应商补充
|
||||||
|
const supplierRows = computed(() => {
|
||||||
|
const groups = quoteGroups.value
|
||||||
|
const rows: any[] = []
|
||||||
|
const seen = new Set<number>()
|
||||||
|
// 已报价企业先入(getQuotationById 分组,企业名非空)
|
||||||
|
for (const g of groups) {
|
||||||
|
if (!g.name || g.name === '未报价') continue
|
||||||
|
const entName = g.name
|
||||||
|
const row = {
|
||||||
|
name: entName,
|
||||||
|
status: 1, // 1=已报价
|
||||||
|
date: g.rows.find((r: any) => r.quotationDate)?.quotationDate || '',
|
||||||
|
count: g.count,
|
||||||
|
total: g.total,
|
||||||
|
rows: g.rows,
|
||||||
|
invited: false,
|
||||||
|
}
|
||||||
|
rows.push(row)
|
||||||
|
}
|
||||||
|
// 受邀供应商:合并状态(邀请表报价状态 0=未报价 1=已报价 2=邀请再次报价)
|
||||||
|
for (const inv of invitedSuppliers.value) {
|
||||||
|
const id = Number(inv.invitedEnterpriseId)
|
||||||
|
const name = inv.enterpriseName || ('企业#' + id)
|
||||||
|
const existing = rows.find(r => r.name === name)
|
||||||
|
const quoted = Number(inv.quotationStatus) === 1
|
||||||
|
if (existing) {
|
||||||
|
// 已有真实报价行 = 已报价,状态不被邀请表状态覆盖(邀请表可能仍标记未响应)
|
||||||
|
existing.invited = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rows.push({
|
||||||
|
name,
|
||||||
|
status: quoted ? 1 : 0,
|
||||||
|
date: '',
|
||||||
|
count: 0,
|
||||||
|
total: 0,
|
||||||
|
rows: [],
|
||||||
|
invited: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
})
|
||||||
|
const invitedSuppliers = ref<any[]>([])
|
||||||
|
async function loadInvited(orderId: any) {
|
||||||
|
try {
|
||||||
|
const res: any = await httpGet('/supplier/quotationinvited/listByOrder', { orderId: String(orderId) })
|
||||||
|
if (res && Array.isArray(res)) invitedSuppliers.value = res
|
||||||
|
else if (res && Array.isArray(res?.list)) invitedSuppliers.value = res.list
|
||||||
|
else invitedSuppliers.value = []
|
||||||
|
} catch { invitedSuppliers.value = [] }
|
||||||
|
}
|
||||||
|
async function loadQuotes(orderId: any) {
|
||||||
|
quoteLoading.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await httpPost('/supplier/inquiry/getQuotationById', { orderId: String(orderId) })
|
||||||
|
const rows = (res && (Array.isArray(res) ? res : [])) || []
|
||||||
|
const groups: any[] = []
|
||||||
|
for (const q of rows) {
|
||||||
|
const name = q.quotationEnterpriseName || '未报价'
|
||||||
|
let g = groups.find(x => x.name === name)
|
||||||
|
if (!g) { g = { name, rows: [], count: 0, total: 0, date: '', list: [] }; groups.push(g) }
|
||||||
|
g.rows.push(q)
|
||||||
|
if (q.quotationDate && !g.date) g.date = q.quotationDate
|
||||||
|
}
|
||||||
|
// 每企业仅保留最新报价轮:明细行 quotation_id 为 19 位雪花 id(超出 Number 安全整数,必须 BigInt 比较)
|
||||||
|
for (const g of groups) {
|
||||||
|
const withQid = g.rows.filter((r: any) => r.quotationId)
|
||||||
|
if (withQid.length) {
|
||||||
|
const latestQid = withQid.reduce((m: string, r: any) => (BigInt(String(r.quotationId)) > BigInt(m) ? String(r.quotationId) : m), String(withQid[0].quotationId))
|
||||||
|
g.rows = g.rows.filter((r: any) => String(r.quotationId) === latestQid)
|
||||||
|
}
|
||||||
|
g.count = g.rows.length
|
||||||
|
g.total = g.rows.reduce((s: number, r: any) => s + Number(r.unitPriceTaxIncluded || r.unitPrice || 0) * Number(r.quantity || 0), 0)
|
||||||
|
}
|
||||||
|
quoteGroups.value = groups
|
||||||
|
} catch { quoteGroups.value = [] } finally { quoteLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 审核操作: apprStatus 2=通过 3=驳回 ===
|
||||||
|
const auditLoading = ref(false)
|
||||||
|
async function onAudit(status: number) {
|
||||||
|
const note = status === 3 ? prompt('请输入驳回原因:') : ''
|
||||||
|
if (status === 3 && note === null) return
|
||||||
|
auditLoading.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await httpPost('/supplier/inquiry/saveInquiryAudit', {
|
||||||
|
id: detail.value?.id,
|
||||||
|
apprStatus: status,
|
||||||
|
...(status === 3 && note ? { auditNote: note } : {}),
|
||||||
|
})
|
||||||
|
// httpPost 已解包到 data: saveInquiryAudit 返回 data=true(布尔), 兼容 code===0
|
||||||
|
if (res === true || res?.code === 0) {
|
||||||
|
ElMessage.success(status === 2 ? '审核通过,询价已发布' : '已驳回')
|
||||||
|
load()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res?.msg || '操作失败')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.msg || e?.message || '操作失败')
|
||||||
|
} finally {
|
||||||
|
auditLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function fmtDate(s: any): string {
|
function fmtDate(s: any): string {
|
||||||
if (!s) return '--'
|
if (!s) return '--'
|
||||||
return String(s).substring(0, 19).replace('T', ' ')
|
return String(s).substring(0, 19).replace('T', ' ')
|
||||||
@ -135,7 +300,35 @@ function quotationInfo(s: number) { return QUOTATION_STATUS_MAP[s] || { label: `
|
|||||||
function inquiryInfo(s: number) { return INQUIRY_STATUS_MAP[s] || { label: `状态${s}`, type: 'info' } }
|
function inquiryInfo(s: number) { return INQUIRY_STATUS_MAP[s] || { label: `状态${s}`, type: 'info' } }
|
||||||
|
|
||||||
function onBack() { router.push('/inquiry') }
|
function onBack() { router.push('/inquiry') }
|
||||||
function onDownloadFile(url: string, name: string) { if (url) ElMessage.info(`下载: ${name || url}`) }
|
// 平台 OSS 域名白名单:响应头为 attachment,走预览代理按扩展名 inline 输出
|
||||||
|
const OSS_PREVIEW_PREFIX = 'https://jubianwangluo.oss-cn-wulanchabu.aliyuncs.com/'
|
||||||
|
// 页内预览弹窗(不再开新窗口,避免弹窗拦截/新标签空白)
|
||||||
|
const previewOpen = ref(false)
|
||||||
|
const previewItem = ref<{ url: string; name: string; kind: 'image' | 'pdf' | 'external' } | null>(null)
|
||||||
|
function fileKind(url: string): 'image' | 'pdf' | 'external' {
|
||||||
|
const lower = (url || '').toLowerCase()
|
||||||
|
if (/\.(png|jpe?g|gif|webp|bmp)(\?|$)/.test(lower)) return 'image'
|
||||||
|
if (/\.pdf(\?|$)/.test(lower)) return 'pdf'
|
||||||
|
return 'external'
|
||||||
|
}
|
||||||
|
function previewSrc(url: string): string {
|
||||||
|
return url.startsWith(OSS_PREVIEW_PREFIX) ? '/other/file/preview?url=' + encodeURIComponent(url) : url
|
||||||
|
}
|
||||||
|
function openExternal(url?: string) { if (url) window.open(url, '_blank') }
|
||||||
|
function onPreviewFile(url: string, name?: string) {
|
||||||
|
if (!url) return
|
||||||
|
previewItem.value = { url: previewSrc(url), name: name || '附件预览', kind: fileKind(url) }
|
||||||
|
previewOpen.value = true
|
||||||
|
}
|
||||||
|
function onDownloadFile(url: string, name: string) {
|
||||||
|
if (!url) return
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = previewSrc(url)
|
||||||
|
if (url.startsWith(OSS_PREVIEW_PREFIX)) a.href = url // OSS 直接下载原始文件
|
||||||
|
a.download = name || ''
|
||||||
|
a.target = '_blank'
|
||||||
|
a.click()
|
||||||
|
}
|
||||||
function onExportPriceComparison() { ElMessage.info('导出功能开发中') }
|
function onExportPriceComparison() { ElMessage.info('导出功能开发中') }
|
||||||
function onViewQuotation(row: Quotation) { ElMessage.info(`查看报价: ${row.quotationEnterpriseName} (请前往询价管理)`) }
|
function onViewQuotation(row: Quotation) { ElMessage.info(`查看报价: ${row.quotationEnterpriseName} (请前往询价管理)`) }
|
||||||
function onQuote(row: Quotation) { ElMessage.info('请前往前台发起报价') }
|
function onQuote(row: Quotation) { ElMessage.info('请前往前台发起报价') }
|
||||||
@ -165,6 +358,10 @@ watch(inquiryId, load)
|
|||||||
</el-tag>
|
</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<div class="rr-header-actions">
|
<div class="rr-header-actions">
|
||||||
|
<template v-if="detail.raw?.apprStatus === 1">
|
||||||
|
<el-button type="success" :loading="auditLoading" @click="onAudit(2)">审核通过</el-button>
|
||||||
|
<el-button type="danger" :loading="auditLoading" @click="onAudit(3)">驳回</el-button>
|
||||||
|
</template>
|
||||||
<el-button @click="onExportPriceComparison">导出基础价格分析表</el-button>
|
<el-button @click="onExportPriceComparison">导出基础价格分析表</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -173,64 +370,132 @@ watch(inquiryId, load)
|
|||||||
<div class="rr-card">
|
<div class="rr-card">
|
||||||
<div class="rr-card-header"><h3 class="rr-card-title">询价信息</h3></div>
|
<div class="rr-card-header"><h3 class="rr-card-title">询价信息</h3></div>
|
||||||
<el-descriptions :column="2" border>
|
<el-descriptions :column="2" border>
|
||||||
<el-descriptions-item label="产品名称">{{ detail.productName }}</el-descriptions-item>
|
<el-descriptions-item label="询价主体(采购方)" :span="2">{{ detail.enterpriseName }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="品牌">{{ detail.productBrand || '--' }}</el-descriptions-item>
|
<el-descriptions-item label="采购类型">{{ detail.procurementTypeName }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="规格型号">{{ detail.specification || '--' }}</el-descriptions-item>
|
<el-descriptions-item label="报价方式">{{ detail.quotationTypeName }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="单位">{{ detail.unit }}</el-descriptions-item>
|
<el-descriptions-item label="采购数量">{{ detail.quantity }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="数量">{{ detail.quantity }}</el-descriptions-item>
|
<el-descriptions-item label="报价要求">{{ detail.quotationDemandName }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="付款方式">{{ detail.paymentMethodName }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="发票要求">{{ detail.invoiceTypeName }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="询价截止">{{ detail.inquiryValidDate }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="要求交货日期">{{ detail.deliveryDate || '--' }}</el-descriptions-item>
|
<el-descriptions-item label="要求交货日期">{{ detail.deliveryDate || '--' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="价格有效截止" :span="2">{{ detail.priceValidDate || '--' }}</el-descriptions-item>
|
<el-descriptions-item label="发布时间" :span="2">{{ detail.createDate || detail.publishDate || '--' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="收货地址" :span="2">{{ detail.shippingAddress || '--' }}</el-descriptions-item>
|
<el-descriptions-item label="收货地址" :span="2">{{ detail.shippingAddress || '--' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="询价备注" :span="2">{{ detail.detailNote || '--' }}</el-descriptions-item>
|
<el-descriptions-item label="询价备注" :span="2">{{ detail.note || '--' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="采购方" :span="2">{{ detail.enterpriseName }}</el-descriptions-item>
|
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 询价附件 -->
|
<!-- 商品明细 -->
|
||||||
|
<div class="rr-card" v-if="(detail.detailList || []).length">
|
||||||
|
<div class="rr-card-header"><h3 class="rr-card-title">商品明细 ({{ (detail.detailList || []).length }})</h3></div>
|
||||||
|
<el-table :data="(detail.detailList || [])" border size="small">
|
||||||
|
<el-table-column type="index" label="#" width="50" align="center" />
|
||||||
|
<el-table-column prop="productName" label="商品名称" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="productBrand" label="品牌" min-width="120">
|
||||||
|
<template #default="{ row }">{{ row.productBrand || '--' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="specification" label="规格" min-width="140">
|
||||||
|
<template #default="{ row }">{{ row.specification || '--' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="quantity" label="数量" width="90" align="center" />
|
||||||
|
<el-table-column prop="unit" label="单位" width="80" align="center" />
|
||||||
|
<el-table-column prop="detailNote" label="备注" min-width="140">
|
||||||
|
<template #default="{ row }">{{ row.detailNote || '--' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 询价附件:图片缩略图网格 + 文件行,预览=页内弹窗 -->
|
||||||
<div class="rr-card" v-if="detail.fileList.length > 0">
|
<div class="rr-card" v-if="detail.fileList.length > 0">
|
||||||
<div class="rr-card-header"><h3 class="rr-card-title">询价附件</h3></div>
|
<div class="rr-card-header"><h3 class="rr-card-title">询价附件 ({{ detail.fileList.length }})</h3></div>
|
||||||
<div class="rr-files">
|
<div class="rr-files">
|
||||||
<div v-for="(f, i) in detail.fileList" :key="i" class="rr-file-item">
|
<div class="rr-attach-grid">
|
||||||
<el-icon><Document /></el-icon>
|
<div v-for="(f, i) in detail.fileList" :key="i" class="rr-attach-item" @click="onPreviewFile(f.url, f.imagesName)">
|
||||||
<span class="rr-file-name">{{ f.imagesName }}</span>
|
<img v-if="fileKind(f.url) === 'image'" :src="previewSrc(f.url)" class="rr-attach-thumb" loading="lazy" />
|
||||||
<el-button text type="primary" size="small" @click="onDownloadFile(f.url, f.imagesName)">下载</el-button>
|
<div v-else class="rr-attach-icon"><el-icon :size="26"><Document /></el-icon><span>{{ fileKind(f.url) === 'pdf' ? 'PDF' : '链接' }}</span></div>
|
||||||
|
<div class="rr-attach-name" :title="f.imagesName">{{ f.imagesName }}</div>
|
||||||
|
<div class="rr-attach-actions" @click.stop>
|
||||||
|
<el-button text type="primary" size="small" @click="onPreviewFile(f.url, f.imagesName)">预览</el-button>
|
||||||
|
<el-button text size="small" @click="onDownloadFile(f.url, f.imagesName)">下载</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 报价列表 -->
|
<!-- 附件预览弹窗(页内) -->
|
||||||
|
<el-dialog v-model="previewOpen" :title="previewItem?.name || '附件预览'" width="96%" top="2vh" destroy-on-close append-to-body>
|
||||||
|
<div class="rr-preview-body">
|
||||||
|
<img v-if="previewItem?.kind === 'image'" :src="previewItem.url" class="rr-preview-img" />
|
||||||
|
<iframe v-else-if="previewItem?.kind === 'pdf'" :src="previewItem.url" class="rr-preview-frame"></iframe>
|
||||||
|
<div v-else class="rr-preview-ext">
|
||||||
|
<p>该附件为外部链接,平台无法在页内预览:</p>
|
||||||
|
<p class="rr-preview-link">{{ previewItem?.url }}</p>
|
||||||
|
<el-button type="primary" @click="openExternal(previewItem?.url)">在新窗口打开</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 报价列表:供应商卡片分区(默认折叠,点击展开全宽明细表,无横向滚动) -->
|
||||||
<div class="rr-card">
|
<div class="rr-card">
|
||||||
<div class="rr-card-header"><h3 class="rr-card-title">报价列表 ({{ detail.quotationList.length }})</h3></div>
|
<div class="rr-card-header"><h3 class="rr-card-title">报价列表 ({{ supplierRows.length }} 家供应商)</h3></div>
|
||||||
<el-table :data="detail.quotationList" border style="width: 100%">
|
<div v-if="quoteLoading" class="rr-empty">加载报价中...</div>
|
||||||
<el-table-column type="index" label="#" width="50" align="center" />
|
<div v-else-if="!supplierRows.length" class="rr-empty">暂无受邀供应商与报价</div>
|
||||||
<el-table-column prop="quotationEnterpriseName" label="供应商名称" min-width="200" show-overflow-tooltip />
|
<div v-else class="rr-sup-list">
|
||||||
<el-table-column label="报价状态" width="120" align="center">
|
<div v-for="(row, ri) in supplierRows" :key="row.name" class="rr-sup-card" :class="{ 'is-open': openSupIndex === ri }">
|
||||||
<template #default="{ row }">
|
<div class="rr-sup-head" @click="toggleSup(ri)">
|
||||||
<el-tag :type="(quotationInfo(row.quotationStatus).type as any)" size="small" effect="light">
|
<div class="rr-sup-title">
|
||||||
{{ quotationInfo(row.quotationStatus).label }}
|
<el-icon class="rr-sup-arrow"><ArrowRight /></el-icon>
|
||||||
</el-tag>
|
<span class="rr-sup-name">{{ row.name }}</span>
|
||||||
</template>
|
<span v-if="Number(row.status) === 1" class="rr-tag rr-tag-green">已报价</span>
|
||||||
</el-table-column>
|
<span v-else class="rr-tag rr-tag-amber">{{ row.invited ? '受邀未报价' : '未报价' }}</span>
|
||||||
<el-table-column label="报价金额" width="140" align="right">
|
</div>
|
||||||
<template #default="{ row }">
|
<div class="rr-sup-meta">
|
||||||
<span v-if="row.totalPrice > 0">{{ fmtMoney(row.totalPrice) }}</span>
|
<span v-if="row.date" class="rr-sup-meta-item">报价时间:{{ row.date }}</span>
|
||||||
<span v-else>--</span>
|
<span class="rr-sup-meta-item">商品 {{ row.count || 0 }} 项</span>
|
||||||
</template>
|
<span class="rr-sup-meta-item rr-sup-total">合计 <b>{{ row.total > 0 ? ('¥ ' + Number(row.total).toLocaleString()) : '--' }}</b></span>
|
||||||
</el-table-column>
|
</div>
|
||||||
<el-table-column label="报价时间" width="170">
|
</div>
|
||||||
<template #default="{ row }">{{ row.quotationDate || '--' }}</template>
|
<div v-if="openSupIndex === ri" class="rr-sup-body">
|
||||||
</el-table-column>
|
<template v-if="row.rows.length">
|
||||||
<el-table-column prop="quotationCountStr" label="报价次数" width="100" align="center" />
|
<el-table :data="row.rows" border size="small">
|
||||||
<el-table-column prop="source" label="来源" width="120" align="center" />
|
<el-table-column type="index" label="#" width="48" align="center" />
|
||||||
<el-table-column label="操作" width="160" align="center" fixed="right">
|
<el-table-column prop="productName" label="商品名称" min-width="150" show-overflow-tooltip />
|
||||||
<template #default="{ row }">
|
<el-table-column label="规格" min-width="110">
|
||||||
<el-button v-if="row.quotationStatus === 1" text type="primary" size="small" @click="onViewQuotation(row)">查看报价</el-button>
|
<template #default="{ row: r }">{{ r.specification || '--' }}</template>
|
||||||
<el-button v-else text type="primary" size="small" @click="onQuote(row)">邀请报价</el-button>
|
</el-table-column>
|
||||||
</template>
|
<el-table-column label="报价品牌" min-width="100">
|
||||||
</el-table-column>
|
<template #default="{ row: r }">{{ r.quotationProductBrand || '--' }}</template>
|
||||||
</el-table>
|
</el-table-column>
|
||||||
|
<el-table-column label="报价单价(含税)" width="120" align="right">
|
||||||
|
<template #default="{ row: r }">¥ {{ Number(r.unitPriceTaxIncluded || r.unitPrice || 0).toLocaleString() }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="税率" width="70" align="center">
|
||||||
|
<template #default="{ row: r }">{{ r.taxRate != null ? Number(r.taxRate) + '%' : '--' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="quantity" label="数量" width="76" align="center" />
|
||||||
|
<el-table-column label="单位" width="62" align="center">
|
||||||
|
<template #default="{ row: r }">{{ r.unit || '件' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="小计" width="110" align="right">
|
||||||
|
<template #default="{ row: r }">¥ {{ (Number(r.unitPriceTaxIncluded || r.unitPrice || 0) * Number(r.quantity || 0)).toLocaleString() }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="生产周期" width="100">
|
||||||
|
<template #default="{ row: r }">{{ r.productionCycle || '--' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="备注" min-width="120">
|
||||||
|
<template #default="{ row: r }">{{ r.quotationNote || '--' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
|
<div v-else class="rr-tip rr-sup-empty">该供应商尚未提交报价</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rr-tip mt10">点击供应商卡片展开/收起该企业的逐商品报价明细(仅显示当前最新一轮报价)</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -246,7 +511,45 @@ watch(inquiryId, load)
|
|||||||
.rr-title { font-size: 20px; font-weight: 600; color: var(--ink); margin: 0 0 4px 0; }
|
.rr-title { font-size: 20px; font-weight: 600; color: var(--ink); margin: 0 0 4px 0; }
|
||||||
.rr-sub { color: var(--ink-3); font-size: 13px; margin: 0; }
|
.rr-sub { color: var(--ink-3); font-size: 13px; margin: 0; }
|
||||||
.rr-header-actions { display: flex; gap: 8px; padding-top: 16px; margin-top: 16px; border-top: 1px solid var(--border); }
|
.rr-header-actions { display: flex; gap: 8px; padding-top: 16px; margin-top: 16px; border-top: 1px solid var(--border); }
|
||||||
.rr-files { padding: 16px 20px; display: flex; flex-direction: column; gap: 8px; }
|
.rr-files { padding: 16px 20px; }
|
||||||
.rr-file-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: var(--bg-soft); border-radius: var(--radius); }
|
.rr-attach-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }
|
||||||
.rr-file-name { flex: 1; color: var(--ink); font-size: 13px; }
|
.rr-attach-item { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--bg-soft); cursor: pointer; transition: box-shadow .15s, border-color .15s; }
|
||||||
|
.rr-attach-item:hover { border-color: var(--el-color-primary-light-5); box-shadow: 0 2px 8px rgba(0,0,0,.06); }
|
||||||
|
.rr-attach-thumb { width: 100%; height: 150px; object-fit: cover; display: block; background: #fff; }
|
||||||
|
.rr-attach-icon { height: 150px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; color: var(--el-text-color-secondary); font-size: 13px; }
|
||||||
|
.rr-attach-icon .el-icon { font-size: 34px; }
|
||||||
|
.rr-attach-name { padding: 8px 10px 2px; font-size: 12px; color: var(--ink); line-height: 1.5; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; word-break: break-all; }
|
||||||
|
.rr-attach-actions { display: flex; justify-content: flex-end; padding: 0 6px 6px; }
|
||||||
|
.rr-preview-body { min-height: 200px; max-height: 84vh; overflow: auto; text-align: center; }
|
||||||
|
.rr-preview-img { max-width: 100%; max-height: 84vh; object-fit: contain; }
|
||||||
|
.rr-preview-frame { width: 100%; height: 82vh; border: 0; }
|
||||||
|
.rr-preview-ext { padding: 30px 20px; }
|
||||||
|
.rr-preview-link { color: var(--el-color-primary); word-break: break-all; margin: 10px 0 16px; }
|
||||||
|
|
||||||
|
.rr-sup-list { padding: 12px 20px; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.rr-sup-card { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; transition: border-color .15s; }
|
||||||
|
.rr-sup-card.is-open { border-color: var(--el-color-primary-light-5); }
|
||||||
|
.rr-sup-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; background: var(--bg-soft); cursor: pointer; flex-wrap: wrap; }
|
||||||
|
.rr-sup-head:hover { background: var(--el-fill-color-light); }
|
||||||
|
.rr-sup-title { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||||
|
.rr-sup-arrow { transition: transform .2s; color: var(--el-text-color-secondary); }
|
||||||
|
.rr-sup-card.is-open .rr-sup-arrow { transform: rotate(90deg); }
|
||||||
|
.rr-sup-name { font-size: 14px; font-weight: 600; color: var(--ink); }
|
||||||
|
.rr-sup-meta { display: flex; align-items: center; gap: 16px; font-size: 12px; color: var(--el-text-color-secondary); flex-wrap: wrap; }
|
||||||
|
.rr-sup-total b { color: var(--el-color-primary); font-size: 14px; }
|
||||||
|
.rr-sup-body { padding: 12px 16px; border-top: 1px solid var(--border); }
|
||||||
|
.rr-sup-empty { padding: 20px; text-align: center; }
|
||||||
|
|
||||||
|
.rr-quote-groups { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.rr-quote-group { border: 1px solid var(--el-border-color); border-radius: 8px; overflow: hidden; }
|
||||||
|
.rr-quote-group-head { display: flex; align-items: center; gap: 8px; padding: 10px 14px; background: var(--el-fill-color-light); font-size: 13px; flex-wrap: wrap; }
|
||||||
|
.rr-quote-meta { margin-left: auto; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||||
|
.rr-tag { font-size: 11px; padding: 1px 8px; border-radius: 10px; background: var(--el-color-info-light-9); color: var(--el-text-color-secondary); }
|
||||||
|
.rr-tag-green { background: #f0f9eb; color: #67c23a; }
|
||||||
|
.rr-empty { padding: 24px; text-align: center; color: var(--el-text-color-secondary); font-size: 13px; }
|
||||||
|
.rr-tip { font-size: 12px; color: var(--el-text-color-secondary); }
|
||||||
|
.mt10 { margin-top: 10px; }
|
||||||
|
.rr-expand { padding: 10px 24px; }
|
||||||
|
.rr-expand-table { max-width: 960px; }
|
||||||
|
.rr-tag-amber { background: #fdf6ec; color: #e6a23c; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -9,19 +9,20 @@
|
|||||||
*/
|
*/
|
||||||
import { onMounted, computed, reactive, ref } from 'vue'
|
import { onMounted, computed, reactive, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||||
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||||
import SearchBar from '@/components/SearchBar/SearchBar.vue'
|
import SearchBar from '@/components/SearchBar/SearchBar.vue'
|
||||||
import ListTabs from '@/components/ListTabs/ListTabs.vue'
|
import ListTabs from '@/components/ListTabs/ListTabs.vue'
|
||||||
import { useInquiryStore, STATUS_OPTIONS } from '@/stores/inquiry'
|
import { useInquiryStore, STATUS_OPTIONS } from '@/stores/inquiry'
|
||||||
import { httpGet } from '@/utils/request'
|
import { httpGet, httpPost } from '@/utils/request'
|
||||||
|
|
||||||
interface InquiryRow {
|
interface InquiryRow {
|
||||||
id: string
|
id: string
|
||||||
customer: string
|
customer: string
|
||||||
product: string
|
product: string
|
||||||
qty: string
|
qty: string
|
||||||
|
hidden?: number
|
||||||
status: 'pending' | 'quoting' | 'done' | 'cancelled' | string
|
status: 'pending' | 'quoting' | 'done' | 'cancelled' | string
|
||||||
time: string | number
|
time: string | number
|
||||||
raw: any
|
raw: any
|
||||||
@ -52,7 +53,8 @@ const limit = ref(10)
|
|||||||
const filters = ref({
|
const filters = ref({
|
||||||
status: '',
|
status: '',
|
||||||
customer: '',
|
customer: '',
|
||||||
searchKey: ''
|
searchKey: '',
|
||||||
|
apprStatus: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
function fmtDate(ts?: string | number | null) {
|
function fmtDate(ts?: string | number | null) {
|
||||||
@ -77,7 +79,8 @@ async function load() {
|
|||||||
const resp = await httpGet<any>('/supplier/inquiry/page', {
|
const resp = await httpGet<any>('/supplier/inquiry/page', {
|
||||||
page: page.value,
|
page: page.value,
|
||||||
limit: limit.value,
|
limit: limit.value,
|
||||||
...(filters.value.status ? { status: filters.value.status } : {})
|
includeHidden: 1,
|
||||||
|
...(filters.value.apprStatus ? { apprStatus: filters.value.apprStatus } : {})
|
||||||
})
|
})
|
||||||
const arr = Array.isArray(resp) ? resp : (resp?.list || [])
|
const arr = Array.isArray(resp) ? resp : (resp?.list || [])
|
||||||
total.value = Number(resp?.total || (arr.length + (page.value - 1) * limit.value))
|
total.value = Number(resp?.total || (arr.length + (page.value - 1) * limit.value))
|
||||||
@ -87,6 +90,8 @@ async function load() {
|
|||||||
product: it.title || it.product || it.productName || '--',
|
product: it.title || it.product || it.productName || '--',
|
||||||
qty: it.purchaseQuantity || it.qty || '--',
|
qty: it.purchaseQuantity || it.qty || '--',
|
||||||
status: it.status || (it.apprStatus != null ? (['pending','quoting','done','cancelled'][it.apprStatus] || 'pending') : 'pending'),
|
status: it.status || (it.apprStatus != null ? (['pending','quoting','done','cancelled'][it.apprStatus] || 'pending') : 'pending'),
|
||||||
|
apprStatus: it.apprStatus,
|
||||||
|
hidden: it.hidden,
|
||||||
time: it.publishDate || it.createDate || it.time,
|
time: it.publishDate || it.createDate || it.time,
|
||||||
raw: it
|
raw: it
|
||||||
}))
|
}))
|
||||||
@ -104,19 +109,84 @@ async function load() {
|
|||||||
|
|
||||||
function onSearch() { page.value = 1; load() }
|
function onSearch() { page.value = 1; load() }
|
||||||
|
|
||||||
const tabItems = computed(() => STATUS_TABS.map(t => ({
|
// 审核状态 tab (运营审核用): ''=全部 1=待审核 2=已通过 3=已驳回
|
||||||
key: t.value,
|
const AUDIT_TABS = [
|
||||||
label: t.label,
|
{ value: '', label: '全部' },
|
||||||
count: t.value === '' ? stats.all : (stats[t.value] || 0)
|
{ value: '1', label: '待审核' },
|
||||||
})))
|
{ value: '2', label: '已通过' },
|
||||||
|
{ value: '3', label: '已驳回' },
|
||||||
|
]
|
||||||
|
const tabItems = computed(() => AUDIT_TABS.map(t => ({ key: t.value, label: t.label })))
|
||||||
function onReset() { filters.value.status = ''; filters.value.customer = ''; filters.value.searchKey = ''; page.value = 1; load() }
|
function onReset() { filters.value.status = ''; filters.value.customer = ''; filters.value.searchKey = ''; page.value = 1; load() }
|
||||||
function onPageChange(p: number) { page.value = p; load() }
|
function onPageChange(p: number) { page.value = p; load() }
|
||||||
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
|
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
|
||||||
function onStatusTab(v: string) {
|
function onStatusTab(v: string) {
|
||||||
filters.value.status = v
|
filters.value.apprStatus = v
|
||||||
page.value = 1
|
page.value = 1
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
// ===== 运营后台 隐藏/删除询价单 =====
|
||||||
|
function reload() { load() }
|
||||||
|
|
||||||
|
async function onHide(row: InquiryRow) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'隐藏后,该询价单将在平台前端(招采广场、结果公示、供应商邀请列表及详情页)完全不可见,运营后台仍可查看。',
|
||||||
|
'隐藏询价单',
|
||||||
|
{ confirmButtonText: '确认隐藏', cancelButtonText: '取消', type: 'warning' }
|
||||||
|
)
|
||||||
|
} catch { return }
|
||||||
|
try {
|
||||||
|
await httpPost('/supplier/inquiry/adminHide', { id: row.id, hidden: 1 })
|
||||||
|
ElMessage.success('已隐藏,平台前端不再展示')
|
||||||
|
reload()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.msg || e?.message || '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUnhide(row: InquiryRow) {
|
||||||
|
try {
|
||||||
|
await httpPost('/supplier/inquiry/adminHide', { id: row.id, hidden: 0 })
|
||||||
|
ElMessage.success('已取消隐藏,恢复前端展示')
|
||||||
|
reload()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.msg || e?.message || '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(row: InquiryRow) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`<div style="line-height:1.9;font-size:13px">
|
||||||
|
<div style="background:#fef0f0;border:1px solid #fbc4c4;color:#c45656;border-radius:6px;padding:8px 12px;margin-bottom:10px;font-weight:600">⚠️ 高危操作,删除后数据不可恢复</div>
|
||||||
|
<div style="font-weight:600;margin-bottom:4px">将彻底删除该询价单及其全部关联数据:</div>
|
||||||
|
<div>① 询价单与全部商品明细 —— 即前端展示的<b>询价公告</b></div>
|
||||||
|
<div>② 全部供应商<b>报价记录</b>与报价明细</div>
|
||||||
|
<div>③ <b>邀请报价记录</b></div>
|
||||||
|
<div style="color:#909399;margin-top:8px">一般场景建议仅做「隐藏」处理;确需删除(测试数据 / 错误录入 / 信息下架)请谨慎确认。</div>
|
||||||
|
</div>`,
|
||||||
|
'删除询价单',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确认删除',
|
||||||
|
cancelButtonText: '取消,仅隐藏即可',
|
||||||
|
type: 'error',
|
||||||
|
confirmButtonClass: 'inq-del-confirm-btn',
|
||||||
|
dangerouslyUseHTMLString: true,
|
||||||
|
customStyle: { maxWidth: '520px' } as any
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch { return }
|
||||||
|
try {
|
||||||
|
const res: any = await httpPost('/supplier/inquiry/adminDelete', { id: row.id })
|
||||||
|
const d = res || {}
|
||||||
|
ElMessage.success(`已删除:报价记录 ${d.quotation ?? 0} 条、报价明细 ${d.quotationDetail ?? 0} 条、邀请记录 ${d.quotationInvited ?? 0} 条、询价明细 ${d.inquiryDetail ?? 0} 条`)
|
||||||
|
reload()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.msg || e?.message || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onRowClick(row: InquiryRow) { router.push(`/inquiry/${row.id}`) }
|
function onRowClick(row: InquiryRow) { router.push(`/inquiry/${row.id}`) }
|
||||||
function onView(row: InquiryRow) { router.push(`/inquiry/${row.id}`) }
|
function onView(row: InquiryRow) { router.push(`/inquiry/${row.id}`) }
|
||||||
|
|
||||||
@ -125,9 +195,9 @@ const columns = computed(() => [
|
|||||||
{ prop: 'customer', label: '客户', minWidth: 180 },
|
{ prop: 'customer', label: '客户', minWidth: 180 },
|
||||||
{ prop: 'product', label: '商品', minWidth: 180 },
|
{ prop: 'product', label: '商品', minWidth: 180 },
|
||||||
{ prop: 'qty', label: '数量', width: 120 },
|
{ prop: 'qty', label: '数量', width: 120 },
|
||||||
{ prop: 'status', label: '状态', width: 100, slot: 'status' },
|
{ prop: 'apprStatus', label: '审核状态', width: 175, slot: 'apprStatus' },
|
||||||
{ prop: 'time', label: '时间', width: 170, slot: 'time' },
|
{ prop: 'time', label: '时间', width: 170, slot: 'time' },
|
||||||
{ label: '操作', width: 100, slot: 'action' }
|
{ label: '操作', width: 265, slot: 'action' }
|
||||||
])
|
])
|
||||||
|
|
||||||
onMounted(() => { load() })
|
onMounted(() => { load() })
|
||||||
@ -165,14 +235,24 @@ onMounted(() => { load() })
|
|||||||
@limit-change="onLimitChange"
|
@limit-change="onLimitChange"
|
||||||
@row-click="onRowClick"
|
@row-click="onRowClick"
|
||||||
>
|
>
|
||||||
<template #status="{ row }">
|
<template #apprStatus="{ row }">
|
||||||
<span :class="['rr-badge', `rr-badge-${row.status}`]">{{ STATUS_LABEL[row.status] || row.status }}</span>
|
<el-tag :type="row.apprStatus === 1 ? 'warning' : (row.apprStatus === 2 ? 'success' : (row.apprStatus === 3 ? 'danger' : 'info'))" size="small">
|
||||||
|
{{ row.apprStatus === 1 ? '待审核' : (row.apprStatus === 2 ? '已通过' : (row.apprStatus === 3 ? '已驳回' : '--')) }}
|
||||||
|
</el-tag>
|
||||||
|
<el-tag v-if="row.hidden === 1" type="info" size="small" effect="dark" style="margin-left: 4px">已隐藏</el-tag>
|
||||||
</template>
|
</template>
|
||||||
<template #time="{ row }">
|
<template #time="{ row }">
|
||||||
<span class="rr-time">{{ fmtDate(row.time) }}</span>
|
<span class="rr-time">{{ fmtDate(row.time) }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template #action="{ row }">
|
<template #action="{ row }">
|
||||||
|
<div class="rr-action-cell">
|
||||||
<el-button text type="primary" size="small" @click.stop="onView(row)">查看</el-button>
|
<el-button text type="primary" size="small" @click.stop="onView(row)">查看</el-button>
|
||||||
|
<el-button v-if="row.hidden !== 1" text type="warning" size="small" @click.stop="onHide(row)">隐藏</el-button>
|
||||||
|
<template v-else>
|
||||||
|
<el-button text type="primary" size="small" @click.stop="onUnhide(row)">取消隐藏</el-button>
|
||||||
|
<el-button text type="danger" size="small" @click.stop="onDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
@ -184,6 +264,12 @@ onMounted(() => { load() })
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rr-action-cell {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.rr-time {
|
.rr-time {
|
||||||
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@ -192,17 +278,33 @@ onMounted(() => { load() })
|
|||||||
|
|
||||||
/* 状态徽章(4 种) - 浅底色块(套设计规范 §5.4) */
|
/* 状态徽章(4 种) - 浅底色块(套设计规范 §5.4) */
|
||||||
.rr-badge {
|
.rr-badge {
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
padding: 2px 8px;
|
align-items: center;
|
||||||
border-radius: 4px;
|
gap: 6px;
|
||||||
font-size: 11.5px;
|
padding: 2px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 17.25px;
|
line-height: 20px;
|
||||||
height: 21.25px;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
.rr-badge::before {
|
||||||
|
content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor;
|
||||||
|
}
|
||||||
.rr-badge-pending { background: var(--amber-soft); color: var(--amber); }
|
.rr-badge-pending { background: var(--amber-soft); color: var(--amber); }
|
||||||
.rr-badge-quoting { background: var(--brand-soft); color: var(--brand); }
|
.rr-badge-quoting { background: var(--brand-soft); color: var(--brand); }
|
||||||
.rr-badge-done { background: var(--green-soft); color: var(--green); }
|
.rr-badge-done { background: var(--green-soft); color: var(--green); }
|
||||||
.rr-badge-cancelled { background: var(--red-soft); color: var(--red); }
|
.rr-badge-cancelled { background: var(--red-soft); color: var(--red); }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* 删除询价单确认弹窗: 确认按钮红色(高危语义) */
|
||||||
|
.inq-del-confirm-btn {
|
||||||
|
background: var(--el-color-danger, #f56c6c) !important;
|
||||||
|
border-color: var(--el-color-danger, #f56c6c) !important;
|
||||||
|
}
|
||||||
|
.inq-del-confirm-btn:hover {
|
||||||
|
background: #e25c5c !important;
|
||||||
|
border-color: #e25c5c !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -49,6 +49,12 @@ const STATUS_MAP: Record<number, { label: string; type: string }> = {
|
|||||||
0: { label: '已停用', type: 'info' }
|
0: { label: '已停用', type: 'info' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// httpGet/httpPost 已解包 code===0 的业务 data(拦截器已 reject 非 0 code),
|
||||||
|
// 拿到值即为成功;兼容对象/布尔/null 返回(与 InquiryDetailView 修复同源)
|
||||||
|
function isOk(r: any) {
|
||||||
|
return !(r && typeof r === 'object' && 'code' in r && Number(r.code) !== 0)
|
||||||
|
}
|
||||||
|
|
||||||
const list = ref<CompanyAccount[]>([])
|
const list = ref<CompanyAccount[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@ -200,7 +206,7 @@ async function takeoverConfirm() {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await httpPost('/sys/openapi/company/takeover', { ...takeoverForm })
|
const res = await httpPost('/sys/openapi/company/takeover', { ...takeoverForm })
|
||||||
if (res?.code !== 0) {
|
if (!isOk(res)) {
|
||||||
ElMessage.error(res?.msg || '接管失败')
|
ElMessage.error(res?.msg || '接管失败')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -224,7 +230,7 @@ function unbindHandle(row: CompanyAccount) {
|
|||||||
id: Number(row.id),
|
id: Number(row.id),
|
||||||
reason: '管理员解绑'
|
reason: '管理员解绑'
|
||||||
})
|
})
|
||||||
if (res?.code !== 0) {
|
if (!isOk(res)) {
|
||||||
ElMessage.error(res?.msg || '解绑失败')
|
ElMessage.error(res?.msg || '解绑失败')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -383,7 +383,7 @@ onMounted(() => { load(); loadCustomers() })
|
|||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 撤销确认弹窗(主题色:info 蓝色 + primary 按钮,因为撤销可恢复) -->
|
<!-- 撤销确认弹窗(主题色:info 蓝色 + primary 按钮,因为撤销可恢复) -->
|
||||||
<el-dialog v-model="revokeVisible" title="撤销 APIKey" width="480px">
|
<el-dialog v-model="revokeVisible" title="撤销 APIKey" width="500px">
|
||||||
<el-alert v-if="revokeTarget" type="info" :closable="false" style="margin-bottom: 16px">
|
<el-alert v-if="revokeTarget" type="info" :closable="false" style="margin-bottom: 16px">
|
||||||
<p>应用: <code class="rr-code-accent">{{ revokeTarget.appId }}</code> ({{ revokeTarget.appName }})</p>
|
<p>应用: <code class="rr-code-accent">{{ revokeTarget.appId }}</code> ({{ revokeTarget.appName }})</p>
|
||||||
<p style="margin-top: 8px;">撤销后 app 将无法继续调用,需要重新申请。</p>
|
<p style="margin-top: 8px;">撤销后 app 将无法继续调用,需要重新申请。</p>
|
||||||
@ -400,7 +400,7 @@ onMounted(() => { load(); loadCustomers() })
|
|||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 删除确认弹窗(物理删除不可逆,保留红色 danger) -->
|
<!-- 删除确认弹窗(物理删除不可逆,保留红色 danger) -->
|
||||||
<el-dialog v-model="deleteVisible" title="物理删除 app" width="480px">
|
<el-dialog v-model="deleteVisible" title="物理删除 app" width="500px">
|
||||||
<el-alert type="error" :closable="false" style="margin-bottom: 16px">
|
<el-alert type="error" :closable="false" style="margin-bottom: 16px">
|
||||||
<p>仅能删除已撤销(status=2)的 app</p>
|
<p>仅能删除已撤销(status=2)的 app</p>
|
||||||
<p>物理删除后无法恢复</p>
|
<p>物理删除后无法恢复</p>
|
||||||
@ -413,7 +413,7 @@ onMounted(() => { load(); loadCustomers() })
|
|||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 重置密钥弹窗 (生成新 appSecret,旧密钥立即作废) -->
|
<!-- 重置密钥弹窗 (生成新 appSecret,旧密钥立即作废) -->
|
||||||
<el-dialog v-model="resetSecretVisible" title="重置 AppSecret" width="560px">
|
<el-dialog v-model="resetSecretVisible" title="重置 AppSecret" width="640px">
|
||||||
<el-alert v-if="!newSecret" type="warning" :closable="false" style="margin-bottom: 16px">
|
<el-alert v-if="!newSecret" type="warning" :closable="false" style="margin-bottom: 16px">
|
||||||
<p>重置后,系统会生成新的 appSecret 并更新数据库 BCrypt 哈希</p>
|
<p>重置后,系统会生成新的 appSecret 并更新数据库 BCrypt 哈希</p>
|
||||||
<p>旧密钥立即作废,Mac 平台助手必须同步更新配置</p>
|
<p>旧密钥立即作废,Mac 平台助手必须同步更新配置</p>
|
||||||
|
|||||||
312
src/modules/order/BackfillManageListView.vue
Normal file
312
src/modules/order/BackfillManageListView.vue
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 补录管理 — admin(订单管理组)
|
||||||
|
*
|
||||||
|
* 板块一 补录企业: 白名单管理(添加=搜索已认证企业开通 /order/user/enterprise/backfillEnabled, 移除=同接口切换)
|
||||||
|
* 板块二 补录订单: /order/orderbackfill/page 全量补录单, 可作废(软删)
|
||||||
|
* 白名单查询: GET /user/enterprise/backfillList (user-server)
|
||||||
|
*/
|
||||||
|
import { ref, onMounted, reactive, computed } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Plus, Search } from '@element-plus/icons-vue'
|
||||||
|
import { httpGet, httpPost } from '@/utils/request'
|
||||||
|
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||||
|
import SearchBar from '@/components/SearchBar/SearchBar.vue'
|
||||||
|
import ListTabs from '@/components/ListTabs/ListTabs.vue'
|
||||||
|
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||||
|
import TableActions from '@/components/TableActions/TableActions.vue'
|
||||||
|
|
||||||
|
const activeTab = ref('ent')
|
||||||
|
const tabItems = [
|
||||||
|
{ key: 'ent', label: '补录企业' },
|
||||||
|
{ key: 'order', label: '补录订单' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// ===== 板块一: 补录企业白名单 =====
|
||||||
|
const entList = ref<any[]>([])
|
||||||
|
const entTotal = ref(0)
|
||||||
|
const entLoading = ref(false)
|
||||||
|
const entPage = ref(1)
|
||||||
|
const entLimit = ref(10)
|
||||||
|
const entFilters = reactive({ enterpriseName: '' })
|
||||||
|
|
||||||
|
const entColumns: any = [
|
||||||
|
{ prop: 'enterprise_name', label: '企业名称', minWidth: 240, slot: 'name' },
|
||||||
|
{ prop: 'current_score', label: '履约评分', width: 110, align: 'center', slot: 'score' },
|
||||||
|
{ prop: 'enabled_time', label: '开通时间', width: 170, slot: 'time' },
|
||||||
|
{ label: '操作', width: 120, align: 'center', slot: 'action' }
|
||||||
|
]
|
||||||
|
|
||||||
|
async function loadEnt() {
|
||||||
|
entLoading.value = true
|
||||||
|
try {
|
||||||
|
const params: any = { page: entPage.value, limit: entLimit.value }
|
||||||
|
if (entFilters.enterpriseName) params.enterpriseName = entFilters.enterpriseName
|
||||||
|
const r: any = await httpGet('/user/enterprise/backfillList', params)
|
||||||
|
entList.value = r?.list || []
|
||||||
|
const t = r?.total
|
||||||
|
entTotal.value = typeof t === 'string' ? parseInt(t, 10) : (Number(t) || 0)
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.msg || '加载失败')
|
||||||
|
} finally { entLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEntSearch() { entPage.value = 1; loadEnt() }
|
||||||
|
function onEntPage(p: number) { entPage.value = p; loadEnt() }
|
||||||
|
function onEntLimit(l: number) { entLimit.value = l; loadEnt() }
|
||||||
|
|
||||||
|
async function onRemove(row: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认关闭【${row.enterprise_name}】的订单补录权限? 关闭后该企业将无法继续补录`, '关闭权限', { type: 'warning' })
|
||||||
|
} catch { return }
|
||||||
|
try {
|
||||||
|
await httpPost(`/user/enterprise/backfillEnabled?id=${row.id}`)
|
||||||
|
ElMessage.success('已关闭补录权限')
|
||||||
|
loadEnt()
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 添加企业弹窗(搜索选择) =====
|
||||||
|
const addDialog = ref(false)
|
||||||
|
const addKeyword = ref('')
|
||||||
|
const addSearching = ref(false)
|
||||||
|
const addCandidates = ref<any[]>([])
|
||||||
|
const addSelected = ref<any>(null)
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
addDialog.value = true
|
||||||
|
addKeyword.value = ''
|
||||||
|
addCandidates.value = []
|
||||||
|
addSelected.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchCandidates() {
|
||||||
|
if (!addKeyword.value.trim()) { ElMessage.warning('请输入企业名称关键词'); return }
|
||||||
|
addSearching.value = true
|
||||||
|
try {
|
||||||
|
const r: any = await httpGet('/user/enterprise/page', { page: 1, limit: 20, status: 2, enterpriseName: addKeyword.value.trim() })
|
||||||
|
addCandidates.value = (r?.list || []).map((it: any) => ({
|
||||||
|
id: String(it.id), name: it.enterpriseName, backfillEnabled: it.backfillEnabled ?? 0
|
||||||
|
}))
|
||||||
|
if (!addCandidates.value.length) ElMessage.info('未搜到已认证企业')
|
||||||
|
} finally { addSearching.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmAdd() {
|
||||||
|
if (!addSelected.value) { ElMessage.warning('请先选择企业'); return }
|
||||||
|
if (addSelected.value.backfillEnabled === 1) { ElMessage.warning('该企业已开通补录权限'); return }
|
||||||
|
try {
|
||||||
|
await httpPost(`/user/enterprise/backfillEnabled?id=${addSelected.value.id}`)
|
||||||
|
ElMessage.success(`已开通【${addSelected.value.name}】的订单补录权限`)
|
||||||
|
addDialog.value = false
|
||||||
|
loadEnt()
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '开通失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 板块二: 补录订单列表 =====
|
||||||
|
const ordList = ref<any[]>([])
|
||||||
|
const ordTotal = ref(0)
|
||||||
|
const ordLoading = ref(false)
|
||||||
|
const ordPage = ref(1)
|
||||||
|
const ordLimit = ref(10)
|
||||||
|
const ordFilters = reactive({ keyword: '' })
|
||||||
|
|
||||||
|
const ordColumns: any = [
|
||||||
|
{ prop: 'supplier_order_code', label: '供应商订单号', width: 215, slot: 'ocode' },
|
||||||
|
{ prop: 'enterprise_name', label: '卖方(供应商)', minWidth: 180 },
|
||||||
|
{ prop: 'buyer', label: '买方', minWidth: 180, slot: 'buyer' },
|
||||||
|
{ prop: 'order_money', label: '订单金额', width: 120, align: 'right', slot: 'money' },
|
||||||
|
{ prop: 'backfill_role', label: '补录方', width: 90, align: 'center', slot: 'role' },
|
||||||
|
{ prop: 'backfill_by', label: '操作人', width: 110, slot: 'by' },
|
||||||
|
{ prop: 'invoice_count', label: '发票', width: 70, align: 'center', slot: 'inv' },
|
||||||
|
{ prop: 'create_date', label: '补录时间', width: 160, slot: 'time' },
|
||||||
|
{ label: '操作', width: 130, align: 'center', slot: 'action' }
|
||||||
|
]
|
||||||
|
|
||||||
|
async function loadOrd() {
|
||||||
|
ordLoading.value = true
|
||||||
|
try {
|
||||||
|
const params: any = { page: ordPage.value, limit: ordLimit.value }
|
||||||
|
if (ordFilters.keyword) params.keyword = ordFilters.keyword
|
||||||
|
const r: any = await httpGet('/order/orderbackfill/page', params)
|
||||||
|
ordList.value = (r?.list || []).map((it: any) => ({
|
||||||
|
...it,
|
||||||
|
buyer: buyerNameCache.value[it.current_enterprise_id] || `企业#${it.current_enterprise_id}`
|
||||||
|
}))
|
||||||
|
fillBuyerNames()
|
||||||
|
const t = r?.total
|
||||||
|
ordTotal.value = typeof t === 'string' ? parseInt(t, 10) : (Number(t) || 0)
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.msg || '加载失败')
|
||||||
|
} finally { ordLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 买方企业名后端未带(跨库), 用 /user/enterprise/getById 批量补显
|
||||||
|
const buyerNameCache = ref<Record<string, string>>({})
|
||||||
|
async function fillBuyerNames() {
|
||||||
|
for (const row of ordList.value) {
|
||||||
|
const bid = String(row.current_enterprise_id)
|
||||||
|
if (!buyerNameCache.value[bid]) {
|
||||||
|
try {
|
||||||
|
const r: any = await httpGet('/user/enterprise/getById', { id: bid })
|
||||||
|
buyerNameCache.value[bid] = r?.enterpriseName || `企业#${bid}`
|
||||||
|
row.buyer = buyerNameCache.value[bid]
|
||||||
|
} catch { /* 保持占位 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOrdSearch() { ordPage.value = 1; loadOrd() }
|
||||||
|
function onOrdPage(p: number) { ordPage.value = p; loadOrd() }
|
||||||
|
function onOrdLimit(l: number) { ordLimit.value = l; loadOrd() }
|
||||||
|
|
||||||
|
async function onVoid(row: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认作废补录单 ${row.supplier_order_code}? 作废后买卖双方均不可见(软删除)`, '作废确认', { type: 'warning' })
|
||||||
|
} catch { return }
|
||||||
|
try {
|
||||||
|
await httpPost('/order/orderbackfill/void', { supplierOrderCode: row.supplier_order_code })
|
||||||
|
ElMessage.success('已作废')
|
||||||
|
loadOrd()
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '作废失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(v: any) { return String(v || '').replace('T', ' ').slice(0, 19) }
|
||||||
|
function money(v: any) { return Number(v || 0).toFixed(2) }
|
||||||
|
|
||||||
|
onMounted(() => { loadEnt() })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rr-page">
|
||||||
|
<PageHeader title="补录管理" sub="控制哪些企业可使用订单补录功能,并查看全部补录订单">
|
||||||
|
<el-button type="primary" :icon="Plus" @click="openAdd">添加补录企业</el-button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<ListTabs v-model="activeTab" :items="tabItems" @change="(v: string) => { activeTab = v }" />
|
||||||
|
|
||||||
|
<!-- 板块一: 补录企业 -->
|
||||||
|
<template v-if="activeTab === 'ent'">
|
||||||
|
<SearchBar
|
||||||
|
v-model:filters="entFilters"
|
||||||
|
:fields="[{ key: 'enterpriseName', label: '企业名称', placeholder: '请输入企业名称' }]"
|
||||||
|
@search="onEntSearch"
|
||||||
|
@reset="() => { entFilters.enterpriseName = ''; onEntSearch() }"
|
||||||
|
/>
|
||||||
|
<DataTable
|
||||||
|
:data="entList"
|
||||||
|
:columns="entColumns"
|
||||||
|
:loading="entLoading"
|
||||||
|
:total="entTotal"
|
||||||
|
:page="entPage"
|
||||||
|
:limit="entLimit"
|
||||||
|
row-key="id"
|
||||||
|
@page-change="onEntPage"
|
||||||
|
@limit-change="onEntLimit"
|
||||||
|
>
|
||||||
|
<template #name="{ row }">
|
||||||
|
<span style="font-weight:600">{{ row.enterprise_name }}</span>
|
||||||
|
<el-tag v-if="Number(row.status) === 2" type="success" size="small" style="margin-left:6px">已认证</el-tag>
|
||||||
|
</template>
|
||||||
|
<template #score="{ row }">
|
||||||
|
<span style="font-weight:700">{{ Number(row.current_score ?? 60) }}</span>
|
||||||
|
<span class="rr-muted" style="font-size:11px"> 分</span>
|
||||||
|
</template>
|
||||||
|
<template #time="{ row }"><span class="rr-time">{{ fmtTime(row.enabled_time) }}</span></template>
|
||||||
|
<template #action="{ row }">
|
||||||
|
<TableActions :row="row" :actions="[{ label: '关闭权限', type: 'danger', onClick: (r: any) => onRemove(r) }]" />
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
<div v-if="!entLoading && !entList.length" class="empty-tip">
|
||||||
|
暂无企业开通补录权限,点击右上角「添加补录企业」开通
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 板块二: 补录订单 -->
|
||||||
|
<template v-else>
|
||||||
|
<SearchBar
|
||||||
|
v-model:filters="ordFilters"
|
||||||
|
:fields="[{ key: 'keyword', label: '关键词', placeholder: '订单号 / 商品名' }]"
|
||||||
|
@search="onOrdSearch"
|
||||||
|
@reset="() => { ordFilters.keyword = ''; onOrdSearch() }"
|
||||||
|
/>
|
||||||
|
<DataTable
|
||||||
|
:data="ordList"
|
||||||
|
:columns="ordColumns"
|
||||||
|
:loading="ordLoading"
|
||||||
|
:total="ordTotal"
|
||||||
|
:page="ordPage"
|
||||||
|
:limit="ordLimit"
|
||||||
|
row-key="supplier_order_code"
|
||||||
|
@page-change="onOrdPage"
|
||||||
|
@limit-change="onOrdLimit"
|
||||||
|
>
|
||||||
|
<template #ocode="{ row }"><span class="rr-time">{{ row.supplier_order_code }}</span></template>
|
||||||
|
<template #buyer="{ row }">{{ row.buyer }}</template>
|
||||||
|
<template #money="{ row }"><b style="color:var(--primary)">¥{{ money(row.order_money) }}</b></template>
|
||||||
|
<template #role="{ row }">
|
||||||
|
<el-tag :type="Number(row.backfill_role) === 1 ? 'warning' : 'primary'" size="small">
|
||||||
|
{{ Number(row.backfill_role) === 1 ? '卖方补录' : '买方补录' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
<template #by="{ row }"><span class="rr-muted">{{ row.backfill_by || '—' }}</span></template>
|
||||||
|
<template #inv="{ row }">
|
||||||
|
<el-tag v-if="Number(row.invoice_count) > 0" type="success" size="small">已开票</el-tag>
|
||||||
|
<span v-else class="rr-muted">—</span>
|
||||||
|
</template>
|
||||||
|
<template #time="{ row }"><span class="rr-time">{{ fmtTime(row.create_date) }}</span></template>
|
||||||
|
<template #action="{ row }">
|
||||||
|
<TableActions :row="row" :actions="[{ label: '作废', type: 'danger', onClick: (r: any) => onVoid(r) }]" />
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
<div v-if="!ordLoading && !ordList.length" class="empty-tip">暂无补录订单</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 添加补录企业弹窗 -->
|
||||||
|
<el-dialog v-model="addDialog" title="添加补录企业" width="520px" destroy-on-close>
|
||||||
|
<div class="add-search">
|
||||||
|
<el-input v-model="addKeyword" placeholder="输入企业名称搜索(仅已认证企业)" clearable @keyup.enter="searchCandidates">
|
||||||
|
<template #append><el-button :icon="Search" :loading="addSearching" @click="searchCandidates">搜索</el-button></template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
<div class="add-list">
|
||||||
|
<div v-for="c in addCandidates" :key="c.id"
|
||||||
|
:class="['add-item', { active: addSelected?.id === c.id }]" @click="addSelected = c">
|
||||||
|
<span class="add-name">{{ c.name }}</span>
|
||||||
|
<el-tag v-if="c.backfillEnabled === 1" type="info" size="small">已开通</el-tag>
|
||||||
|
<span v-else class="add-check">✓</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!addCandidates.length && !addSearching" class="rr-muted" style="text-align:center;padding:24px 0">
|
||||||
|
输入关键词搜索并选择要开通的企业
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="addDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" :disabled="!addSelected || addSelected.backfillEnabled === 1" @click="confirmAdd">开通补录权限</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.rr-page { max-width: 1500px; }
|
||||||
|
.rr-muted { color: var(--ink-3); }
|
||||||
|
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--ink-2); }
|
||||||
|
.empty-tip { text-align: center; color: var(--ink-3); padding: 28px 0; font-size: 13px; }
|
||||||
|
.add-search { margin-bottom: 12px; }
|
||||||
|
.add-list { max-height: 320px; overflow-y: auto; border: 1px solid var(--border-1, #e5e7eb); border-radius: 10px; }
|
||||||
|
.add-item {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
||||||
|
padding: 11px 14px; cursor: pointer; border-bottom: 1px solid var(--border-1, #e5e7eb);
|
||||||
|
transition: background .12s;
|
||||||
|
}
|
||||||
|
.add-item:last-child { border-bottom: none; }
|
||||||
|
.add-item:hover { background: var(--bg-2, #f8fafc); }
|
||||||
|
.add-item.active { background: var(--primary-soft, #eff6ff); box-shadow: inset 0 0 0 1px var(--primary); }
|
||||||
|
.add-name { font-size: 13.5px; font-weight: 600; color: var(--ink); }
|
||||||
|
.add-check {
|
||||||
|
width: 18px; height: 18px; border-radius: 50%; background: var(--primary); color: #fff;
|
||||||
|
font-size: 11px; text-align: center; line-height: 18px; display: none;
|
||||||
|
}
|
||||||
|
.add-item.active .add-check { display: block; }
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,24 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* F006 订单管理列表 - W9 A3 标准化示范
|
* 订单管理列表(商品订单) — 真实业务数据版
|
||||||
*
|
*
|
||||||
* 沿用老 vue order/order-good.vue 289 行 + order/serviceorder 154 行等:
|
* 数据源: GET /order/ordergood/adminPageList(order-server 后台管理分页, 全量订单, 无企业隔离)
|
||||||
* - 搜索: 订单编号/合同编号/供应商/采购方/状态/创建时间范围
|
* - 过滤: status / orderCode(OP号) / enterpriseName(供应商) / currentEnterpriseName(采购方) / startDate / endDate
|
||||||
* - 状态 tab: 全部/待支付/待发货/待收货/待评价/已完成/已取消
|
* - orderGoodType 必传: 1=普通/秒杀 2=拼团
|
||||||
* - 表格列: 订单号/合同号/供应商/采购方/订单类型/金额/已发/状态/时间
|
* 详情: 跳转 /order/{supplierOrderCode} → OrderDetailView(只读, 信息维度与工作台订单详情一致)
|
||||||
* - 操作: 详情/付款/发货/收货/取消/退款/导出
|
|
||||||
* - 批量导出
|
|
||||||
* - 通过 query.type 区分 good/service/group
|
|
||||||
*
|
*
|
||||||
* 字段: id/orderNo/contractCode/enterpriseName/orderUserName/orderType/totalAmount/totalNum/shippedNum/status/createDate/payDate/payType/pickupType
|
* 历史: W12 版本错挂平台助手 MCP 端点 /sys/openapi/order/list, 供应商/采购方/合同号全空,
|
||||||
* 端点: /order/ordergood/page 200 (按 type 区分 good/service/group)
|
* 详情页更用 FALLBACK 模拟数据兜底(公司=深圳科技、物流=顺丰均为假数据)。
|
||||||
*
|
|
||||||
* W9 A3: 用 PageHeader / SearchBar 组件替换硬编码, 用 DataTable type:'selection' 替换裸表
|
|
||||||
*/
|
*/
|
||||||
import { ref, onMounted, reactive, computed } from 'vue'
|
import { ref, onMounted, reactive, computed } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { httpGet } from '@/utils/request'
|
import { Clock } from '@element-plus/icons-vue'
|
||||||
|
import { httpGet, httpPost } from '@/utils/request'
|
||||||
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||||
import SearchBar from '@/components/SearchBar/SearchBar.vue'
|
import SearchBar from '@/components/SearchBar/SearchBar.vue'
|
||||||
import ListTabs from '@/components/ListTabs/ListTabs.vue'
|
import ListTabs from '@/components/ListTabs/ListTabs.vue'
|
||||||
@ -26,26 +22,31 @@ import DataTable from '@/components/DataTable/DataTable.vue'
|
|||||||
import TableActions from '@/components/TableActions/TableActions.vue'
|
import TableActions from '@/components/TableActions/TableActions.vue'
|
||||||
|
|
||||||
interface Order {
|
interface Order {
|
||||||
id: string
|
supplierOrderCode: string
|
||||||
orderNo: string
|
|
||||||
contractCode: string
|
contractCode: string
|
||||||
enterpriseName: string
|
supplierName: string
|
||||||
orderUserName: string
|
buyerName: string
|
||||||
orderType: number
|
goodType: number
|
||||||
totalAmount: number
|
totalAmount: number
|
||||||
totalNum: number
|
totalNum: number
|
||||||
shippedNum: number
|
shippedNum: number
|
||||||
status: number
|
status: number
|
||||||
createDate: string
|
createDate: string
|
||||||
payDate: string | null
|
payDate: string | null
|
||||||
payType: number
|
|
||||||
pickupType: number
|
|
||||||
raw: any
|
raw: any
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABEL: Record<number, string> = {
|
const STATUS_LABEL: Record<number, string> = {
|
||||||
1: '待支付', 2: '待发货', 3: '待收货', 4: '待付尾款', 5: '待评价', 6: '已完成', 7: '已取消', 8: '采购单待审核', 9: '付款单待审核'
|
1: '待支付', 2: '待发货', 3: '待收货', 4: '待付尾款', 5: '待评价', 6: '已完成', 7: '已取消', 8: '采购单待审核', 9: '付款单待审核'
|
||||||
}
|
}
|
||||||
|
// 状态胶囊配色 (已完成=绿/已取消=灰/其余=品牌蓝, 引用全局 --st-* 变量暗色自适应)
|
||||||
|
const STATUS_PILL: Record<number, { color: string; bg: string }> = {
|
||||||
|
6: { color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
|
||||||
|
7: { color: 'var(--st-lv4-fg)', bg: 'var(--st-lv4-bg)' }
|
||||||
|
}
|
||||||
|
function statusPill(status: number) {
|
||||||
|
return STATUS_PILL[status] || { color: 'var(--primary)', bg: 'var(--primary-soft, #dbeafe)' }
|
||||||
|
}
|
||||||
|
|
||||||
const STATUS_TABS = [
|
const STATUS_TABS = [
|
||||||
{ value: '', label: '全部' },
|
{ value: '', label: '全部' },
|
||||||
@ -58,17 +59,6 @@ const STATUS_TABS = [
|
|||||||
{ value: '7', label: '已取消' }
|
{ value: '7', label: '已取消' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const FALLBACK: Order[] = [
|
|
||||||
{ id: 'O001', orderNo: 'O20260705001', contractCode: 'HT-2026-07001', enterpriseName: '深圳科技股份有限公司', orderUserName: '深圳科技股份有限公司', orderType: 1, totalAmount: 12500, totalNum: 5, shippedNum: 5, status: 3, createDate: '2026-07-05 10:24:33', payDate: '2026-07-05 10:35:12', payType: 1, pickupType: 1, raw: {} },
|
|
||||||
{ id: 'O002', orderNo: 'O20260705002', contractCode: 'HT-2026-07002', enterpriseName: '上海制造企业', orderUserName: '中建八局', orderType: 1, totalAmount: 158500, totalNum: 100, shippedNum: 60, status: 2, createDate: '2026-07-05 09:15:00', payDate: '2026-07-05 09:30:00', payType: 2, pickupType: 1, raw: {} },
|
|
||||||
{ id: 'O003', orderNo: 'O20260705003', contractCode: '', enterpriseName: '北京电子有限公司', orderUserName: '北京城建', orderType: 2, totalAmount: 45800, totalNum: 20, shippedNum: 0, status: 1, createDate: '2026-07-04 16:42:00', payDate: null, payType: 0, pickupType: 1, raw: {} },
|
|
||||||
{ id: 'O004', orderNo: 'O20260705004', contractCode: 'HT-2026-07004', enterpriseName: '广州工厂', orderUserName: '广州建筑', orderType: 1, totalAmount: 89000, totalNum: 1, shippedNum: 0, status: 4, createDate: '2026-07-04 14:30:00', payDate: '2026-07-04 14:35:00', payType: 3, pickupType: 1, raw: {} },
|
|
||||||
{ id: 'O005', orderNo: 'O20260705005', contractCode: '', enterpriseName: '杭州电子', orderUserName: '杭州电子', orderType: 1, totalAmount: 320000, totalNum: 1, shippedNum: 1, status: 5, createDate: '2026-07-04 11:08:00', payDate: '2026-07-04 11:10:00', payType: 1, pickupType: 2, raw: {} },
|
|
||||||
{ id: 'O006', orderNo: 'O20260705006', contractCode: 'HT-2026-07006', enterpriseName: '深圳科技股份有限公司', orderUserName: '上海建工', orderType: 1, totalAmount: 45000, totalNum: 10, shippedNum: 0, status: 6, createDate: '2026-07-04 10:00:00', payDate: '2026-07-04 10:05:00', payType: 1, pickupType: 1, raw: {} },
|
|
||||||
{ id: 'O007', orderNo: 'O20260705007', contractCode: '', enterpriseName: '上海制造企业', orderUserName: '上海建工', orderType: 1, totalAmount: 56000, totalNum: 3, shippedNum: 0, status: 7, createDate: '2026-07-04 09:00:00', payDate: null, payType: 0, pickupType: 1, raw: {} },
|
|
||||||
{ id: 'O008', orderNo: 'O20260705008', contractCode: 'HT-2026-07008', enterpriseName: '北京电子有限公司', orderUserName: '北京城建', orderType: 2, totalAmount: 120000, totalNum: 1, shippedNum: 1, status: 3, createDate: '2026-07-04 08:30:00', payDate: '2026-07-04 08:35:00', payType: 2, pickupType: 1, raw: {} }
|
|
||||||
]
|
|
||||||
|
|
||||||
const list = ref<Order[]>([])
|
const list = ref<Order[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@ -76,9 +66,9 @@ const error = ref<string | null>(null)
|
|||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const limit = ref(10)
|
const limit = ref(10)
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
orderNo: '',
|
orderCode: '',
|
||||||
contractCode: '',
|
|
||||||
enterpriseName: '',
|
enterpriseName: '',
|
||||||
|
currentEnterpriseName: '',
|
||||||
status: '',
|
status: '',
|
||||||
startDate: '',
|
startDate: '',
|
||||||
endDate: ''
|
endDate: ''
|
||||||
@ -86,23 +76,18 @@ const filters = reactive({
|
|||||||
const selection = ref<Order[]>([])
|
const selection = ref<Order[]>([])
|
||||||
const columns: any = computed(() => [
|
const columns: any = computed(() => [
|
||||||
{ type: 'selection', label: '', width: 50 },
|
{ type: 'selection', label: '', width: 50 },
|
||||||
{ prop: 'orderNo', label: '订单号', width: 180 },
|
{ prop: 'supplierOrderCode', label: '订单号', width: 200, slot: 'orderNo' },
|
||||||
{ prop: 'contractCode', label: '合同号', width: 160, slot: 'contractCode' },
|
{ prop: 'contractCode', label: '合同号', width: 150, slot: 'contractCode' },
|
||||||
{ prop: 'enterpriseName', label: '供应商', minWidth: 180, slot: 'enterpriseName' },
|
{ prop: 'supplierName', label: '供应商', minWidth: 170, slot: 'supplier' },
|
||||||
{ prop: 'orderUserName', label: '采购方', minWidth: 160, slot: 'orderUserName' },
|
{ prop: 'buyerName', label: '采购方', minWidth: 160, slot: 'buyer' },
|
||||||
{ prop: 'totalAmount', label: '金额', width: 130, align: 'right', slot: 'amount' },
|
{ prop: 'totalAmount', label: '金额', width: 120, align: 'right', slot: 'amount' },
|
||||||
{ prop: 'totalNum', label: '数量', width: 80, align: 'center' },
|
{ prop: 'totalNum', label: '数量', width: 70, align: 'center' },
|
||||||
{ prop: 'shippedNum', label: '已发', width: 80, align: 'center' },
|
{ prop: 'shippedNum', label: '已发', width: 70, align: 'center' },
|
||||||
{ prop: 'status', label: '状态', width: 110, align: 'center', slot: 'status' },
|
{ prop: 'status', label: '状态', width: 100, align: 'center', slot: 'status' },
|
||||||
{ prop: 'createDate', label: '下单时间', width: 170, slot: 'time' },
|
{ prop: 'createDate', label: '下单时间', width: 180, slot: 'time' },
|
||||||
{ label: '操作', width: 140, align: 'center', slot: 'action' }
|
{ label: '操作', width: 140, align: 'center', slot: 'action' }
|
||||||
])
|
])
|
||||||
const tabItems = computed(() => STATUS_TABS.map(t => ({
|
const tabItems = computed(() => STATUS_TABS.map(t => ({ key: t.value, label: t.label })))
|
||||||
key: t.value,
|
|
||||||
label: t.label,
|
|
||||||
count: t.value === '' ? stats.all : (stats[Number(t.value)] || 0)
|
|
||||||
})))
|
|
||||||
const stats = reactive<Record<number|string, number>>({ 0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, all: 0 })
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@ -112,50 +97,51 @@ const listType = computed(() => {
|
|||||||
if (path.includes('/group-supplier') || path.includes('/groupsupplierorder')) return 'groupsupplier'
|
if (path.includes('/group-supplier') || path.includes('/groupsupplierorder')) return 'groupsupplier'
|
||||||
if (path.includes('/group-service') || path.includes('/groupserviceorder')) return 'groupservice'
|
if (path.includes('/group-service') || path.includes('/groupserviceorder')) return 'groupservice'
|
||||||
if (path.includes('/after')) return 'aftersale'
|
if (path.includes('/after')) return 'aftersale'
|
||||||
if (path.includes('/commission')) return 'commission'
|
|
||||||
return 'good'
|
return 'good'
|
||||||
})
|
})
|
||||||
|
|
||||||
// W12.2 修正: 后端 OpenApiOrderController 只暴露 /openapi/order/list 一个端点,
|
// 后端后台分页: orderGoodType 必传 (1=商品订单 2=拼团订单)
|
||||||
// 旧路径 (/order/ordergood/page 等) 404。所有 5 种订单类型都走统一端点。
|
const endpoint = computed(() => '/order/ordergood/adminPageList')
|
||||||
const endpoint = computed(() => '/sys/openapi/order/list')
|
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
// W12.3 修复: httpGet() 已解开 res.data.data, 所以 r 直接是 {total, list, page, limit}
|
const params: any = {
|
||||||
const r: any = await httpGet(endpoint.value, { page: page.value, limit: limit.value })
|
page: page.value,
|
||||||
|
limit: limit.value,
|
||||||
|
orderGoodType: listType.value === 'groupsupplier' ? 2 : 1
|
||||||
|
}
|
||||||
|
if (filters.status) params.status = filters.status
|
||||||
|
if (filters.orderCode) params.orderCode = filters.orderCode
|
||||||
|
if (filters.enterpriseName) params.enterpriseName = filters.enterpriseName
|
||||||
|
if (filters.currentEnterpriseName) params.currentEnterpriseName = filters.currentEnterpriseName
|
||||||
|
if (filters.startDate) params.startDate = filters.startDate
|
||||||
|
if (filters.endDate) params.endDate = filters.endDate
|
||||||
|
const r: any = await httpGet(endpoint.value, params)
|
||||||
const respList: any[] = Array.isArray(r?.list) ? r.list : []
|
const respList: any[] = Array.isArray(r?.list) ? r.list : []
|
||||||
const rows: Order[] = respList.map((it: any) => ({
|
const rows: Order[] = respList.map((it: any) => ({
|
||||||
id: it.orderId || '',
|
supplierOrderCode: it.supplierOrderCode || it.orderCode || '',
|
||||||
orderNo: it.orderId || '',
|
contractCode: it.contractCode || '',
|
||||||
contractCode: '',
|
supplierName: it.enterpriseName || '',
|
||||||
enterpriseName: '',
|
buyerName: it.currentEnterpriseName || it.orderUserName || (it.nickName ? `${it.nickName}${it.mobile ? ' (' + it.mobile + ')' : ''}` : ''),
|
||||||
orderUserName: '',
|
goodType: Number(it.goodType ?? 1),
|
||||||
orderType: 1,
|
totalAmount: Number(it.orderMoney ?? it.sum ?? 0),
|
||||||
totalAmount: Number(it.totalAmount ?? 0),
|
totalNum: Number(it.totalNum ?? 0),
|
||||||
totalNum: Number(it.goodsQuantity ?? 0),
|
shippedNum: Number(it.shippedNum ?? 0),
|
||||||
shippedNum: 0,
|
|
||||||
status: Number(it.status ?? 0),
|
status: Number(it.status ?? 0),
|
||||||
createDate: it.createDate ? String(it.createDate) : '',
|
createDate: it.createDate ? String(it.createDate) : '',
|
||||||
payDate: null,
|
payDate: it.payDate ? String(it.payDate) : null,
|
||||||
payType: 0,
|
|
||||||
pickupType: 1,
|
|
||||||
raw: it
|
raw: it
|
||||||
}))
|
}))
|
||||||
list.value = rows
|
list.value = rows
|
||||||
// 后端 total 是字符串 "1356", parseInt 兜底
|
// 后端 total 是字符串 "1356", parseInt 兜底
|
||||||
const t = r?.total
|
const t = r?.total
|
||||||
total.value = typeof t === 'string' ? parseInt(t, 10) : (Number(t) || 0)
|
total.value = typeof t === 'string' ? parseInt(t, 10) : (Number(t) || 0)
|
||||||
// 各 status 计数
|
|
||||||
Object.keys(stats).forEach(k => (stats as any)[k] = 0)
|
|
||||||
stats.all = rows.length
|
|
||||||
rows.forEach(r => { stats[r.status] = ((stats[r.status] as number) || 0) + 1 })
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e?.msg || e?.message || '加载失败'
|
error.value = e?.msg || e?.message || '加载失败'
|
||||||
list.value = FALLBACK
|
list.value = []
|
||||||
total.value = FALLBACK.length
|
total.value = 0
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@ -178,9 +164,9 @@ function fmtDate(ts?: string | number | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onReset() {
|
function onReset() {
|
||||||
filters.orderNo = ''
|
filters.orderCode = ''
|
||||||
filters.contractCode = ''
|
|
||||||
filters.enterpriseName = ''
|
filters.enterpriseName = ''
|
||||||
|
filters.currentEnterpriseName = ''
|
||||||
filters.status = ''
|
filters.status = ''
|
||||||
filters.startDate = ''
|
filters.startDate = ''
|
||||||
filters.endDate = ''
|
filters.endDate = ''
|
||||||
@ -191,18 +177,77 @@ function onLimitChange(l: number) { limit.value = l; load() }
|
|||||||
function onSelectionChange(rows: Order[]) { selection.value = rows }
|
function onSelectionChange(rows: Order[]) { selection.value = rows }
|
||||||
|
|
||||||
function onStatusTabChange(status: string) { filters.status = status; onSearch() }
|
function onStatusTabChange(status: string) { filters.status = status; onSearch() }
|
||||||
function onViewDetail(row: Order) { router.push(`/order/${row.id}`) }
|
function onViewDetail(row: Order) { router.push(`/order/${row.supplierOrderCode}`) }
|
||||||
function onPay(row: Order) { ElMessage.info('请前往前台完成支付') }
|
|
||||||
function onShip(row: Order) { ElMessage.info('请前往供应商端发货') }
|
|
||||||
function onConfirmReceipt(row: Order) { ElMessage.info('请前往前台确认收货') }
|
|
||||||
function onRefund(row: Order) { ElMessage.info('请前往售后管理处理退款') }
|
|
||||||
function onCancel(row: Order) { ElMessage.info('请前往前台取消订单') }
|
|
||||||
function onPrint(row: Order) { window.print() }
|
|
||||||
function onExportSelected() {
|
function onExportSelected() {
|
||||||
if (!selection.value.length) { ElMessage.warning('请先选择订单'); return }
|
if (!selection.value.length) { ElMessage.warning('请先选择订单'); return }
|
||||||
ElMessage.info('导出功能开发中')
|
ElMessage.info('导出功能开发中')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 平台服务费(居间费)设置 =====
|
||||||
|
const feeDialog = ref(false)
|
||||||
|
const feeRow = ref<Order | null>(null)
|
||||||
|
const feeLoading = ref(false)
|
||||||
|
const feeExist = ref<any>(null) // 已有服务费详情
|
||||||
|
const feeForm = reactive({ feeType: 1, feeRate: '1', fixedMoney: '' })
|
||||||
|
const feeSubmitting = ref(false)
|
||||||
|
|
||||||
|
async function onServiceFee(row: Order) {
|
||||||
|
feeRow.value = row
|
||||||
|
feeExist.value = null
|
||||||
|
feeForm.feeType = 1
|
||||||
|
feeForm.feeRate = '1'
|
||||||
|
feeForm.fixedMoney = ''
|
||||||
|
feeDialog.value = true
|
||||||
|
feeLoading.value = true
|
||||||
|
try {
|
||||||
|
const r: any = await httpGet('/order/platformservicefee/detail', { orderCode: row.supplierOrderCode })
|
||||||
|
feeExist.value = r || null
|
||||||
|
} catch { feeExist.value = null }
|
||||||
|
feeLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const feePreview = computed(() => {
|
||||||
|
if (feeExist.value) return ''
|
||||||
|
if (feeForm.feeType === 1) {
|
||||||
|
const r = Number(feeForm.feeRate) || 0
|
||||||
|
return ((feeRow.value?.totalAmount || 0) * r / 100).toFixed(2)
|
||||||
|
}
|
||||||
|
return (Number(feeForm.fixedMoney) || 0).toFixed(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
const FEE_STATUS: Record<number, string> = { 1: '待付款', 2: '部分已付', 3: '已付清', 4: '已作废' }
|
||||||
|
const PERIOD_STATUS: Record<number, string> = { 1: '待支付', 2: '待确认', 3: '已支付' }
|
||||||
|
|
||||||
|
async function onFeeSubmit() {
|
||||||
|
if (!feeRow.value) return
|
||||||
|
const v = feeForm.feeType === 1 ? feeForm.feeRate : feeForm.fixedMoney
|
||||||
|
if (!v || Number(v) <= 0) { ElMessage.warning(feeForm.feeType === 1 ? '请输入比例' : '请输入固定金额'); return }
|
||||||
|
feeSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await httpPost('/order/platformservicefee/set', {
|
||||||
|
orderCode: feeRow.value.supplierOrderCode,
|
||||||
|
feeType: feeForm.feeType,
|
||||||
|
feeRate: feeForm.feeType === 1 ? v : v
|
||||||
|
})
|
||||||
|
ElMessage.success('服务费设置成功, 已生成供应商应付款')
|
||||||
|
feeDialog.value = false
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.msg || e?.message || '设置失败')
|
||||||
|
} finally { feeSubmitting.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFeeCancelExist() {
|
||||||
|
if (!feeExist.value) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确认作废该订单的服务费? 作废后可重新设置', '作废确认', { type: 'warning' })
|
||||||
|
} catch { return }
|
||||||
|
try {
|
||||||
|
await httpPost('/order/platformservicefee/cancel', { id: feeExist.value.id })
|
||||||
|
ElMessage.success('已作废')
|
||||||
|
onServiceFee(feeRow.value!)
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '作废失败') }
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -219,9 +264,9 @@ onMounted(load)
|
|||||||
<SearchBar
|
<SearchBar
|
||||||
v-model:filters="filters"
|
v-model:filters="filters"
|
||||||
:fields="[
|
:fields="[
|
||||||
{ key: 'orderNo', label: '订单号', placeholder: '请输入订单号' },
|
{ key: 'orderCode', label: '订单号', placeholder: '请输入订单号' },
|
||||||
{ key: 'contractCode', label: '合同号', placeholder: '请输入合同号' },
|
|
||||||
{ key: 'enterpriseName', label: '供应商', placeholder: '请输入供应商名称' },
|
{ key: 'enterpriseName', label: '供应商', placeholder: '请输入供应商名称' },
|
||||||
|
{ key: 'currentEnterpriseName', label: '采购方', placeholder: '请输入采购方企业名称' },
|
||||||
{ key: 'startDate', label: '开始日期', type: 'date', placeholder: '选择开始日期' },
|
{ key: 'startDate', label: '开始日期', type: 'date', placeholder: '选择开始日期' },
|
||||||
{ key: 'endDate', label: '结束日期', type: 'date', placeholder: '选择结束日期' }
|
{ key: 'endDate', label: '结束日期', type: 'date', placeholder: '选择结束日期' }
|
||||||
]"
|
]"
|
||||||
@ -241,59 +286,239 @@ onMounted(load)
|
|||||||
:error="error"
|
:error="error"
|
||||||
empty-text="暂无订单"
|
empty-text="暂无订单"
|
||||||
:borderless="true"
|
:borderless="true"
|
||||||
row-key="id"
|
row-key="supplierOrderCode"
|
||||||
@page-change="onPageChange"
|
@page-change="onPageChange"
|
||||||
@limit-change="onLimitChange"
|
@limit-change="onLimitChange"
|
||||||
@selection-change="onSelectionChange"
|
@selection-change="onSelectionChange"
|
||||||
>
|
>
|
||||||
|
<template #orderNo="{ row }">
|
||||||
|
<span class="rr-order-no">{{ row.supplierOrderCode }}</span>
|
||||||
|
</template>
|
||||||
<template #contractCode="{ row }">
|
<template #contractCode="{ row }">
|
||||||
<span v-if="row.contractCode">{{ row.contractCode }}</span>
|
<span v-if="row.contractCode">{{ row.contractCode }}</span>
|
||||||
<span v-else class="rr-muted">--</span>
|
<span v-else class="rr-muted">--</span>
|
||||||
</template>
|
</template>
|
||||||
<template #enterpriseName="{ row }">
|
<template #supplier="{ row }">
|
||||||
<span v-if="row.enterpriseName">{{ row.enterpriseName }}</span>
|
<span v-if="row.supplierName">{{ row.supplierName }}</span>
|
||||||
<span v-else class="rr-muted">--</span>
|
<span v-else class="rr-muted">--</span>
|
||||||
</template>
|
</template>
|
||||||
<template #orderUserName="{ row }">
|
<template #buyer="{ row }">
|
||||||
<span v-if="row.orderUserName">{{ row.orderUserName }}</span>
|
<span v-if="row.buyerName">{{ row.buyerName }}</span>
|
||||||
<span v-else class="rr-muted">--</span>
|
<span v-else class="rr-muted">--</span>
|
||||||
</template>
|
</template>
|
||||||
<template #time="{ row }">
|
<template #time="{ row }">
|
||||||
<span class="rr-time">{{ fmtDate(row.createDate) }}</span>
|
<span class="rr-time"><el-icon style="vertical-align:-2px;margin-right:4px"><Clock /></el-icon>{{ fmtDate(row.createDate) }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template #amount="{ row }">
|
<template #amount="{ row }">
|
||||||
<span class="rr-amount">¥ {{ row.totalAmount.toLocaleString() }}</span>
|
<span class="rr-amount">¥ {{ row.totalAmount.toLocaleString() }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template #status="{ row }">
|
<template #status="{ row }">
|
||||||
<el-tag
|
<span class="st-pill" :style="{ color: statusPill(row.status).color, background: statusPill(row.status).bg }">
|
||||||
:type="(STATUS_LABEL[row.status] === '已完成' ? 'success' : STATUS_LABEL[row.status] === '已取消' ? 'info' : 'primary') as any"
|
<span class="st-dot"></span>{{ STATUS_LABEL[row.status] || `状态${row.status}` }}
|
||||||
size="small"
|
</span>
|
||||||
effect="light"
|
|
||||||
>
|
|
||||||
{{ STATUS_LABEL[row.status] || `状态${row.status}` }}
|
|
||||||
</el-tag>
|
|
||||||
</template>
|
</template>
|
||||||
<template #action="{ row }">
|
<template #action="{ row }">
|
||||||
<TableActions
|
<TableActions
|
||||||
:row="row"
|
:row="row"
|
||||||
:actions="[
|
:actions="[
|
||||||
{ label: '详情', type: 'primary', onClick: onViewDetail },
|
{ label: '详情', type: 'primary', onClick: onViewDetail },
|
||||||
{ label: '付款', type: 'primary', when: (r: any) => r.status === 1, onClick: onPay },
|
{ label: '服务费', type: 'primary', onClick: onServiceFee }
|
||||||
{ label: '发货', type: 'primary', when: (r: any) => r.status === 2, onClick: onShip },
|
|
||||||
{ label: '收货', type: 'primary', when: (r: any) => r.status === 3, onClick: onConfirmReceipt },
|
|
||||||
{ label: '退款', type: 'warning', when: (r: any) => r.status === 4, onClick: onRefund },
|
|
||||||
{ label: '取消', type: 'danger', when: (r: any) => [1,2].includes(r.status), onClick: onCancel },
|
|
||||||
{ label: '打印', type: 'primary', onClick: onPrint }
|
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
|
||||||
|
<!-- 平台服务费设置弹窗 -->
|
||||||
|
<el-dialog v-model="feeDialog" title="平台服务费(居间费)" width="660px" destroy-on-close class="fee-dialog">
|
||||||
|
<div v-if="feeLoading" style="padding:40px 0;text-align:center;color:var(--ink-3)">加载中...</div>
|
||||||
|
<template v-else-if="feeExist && feeRow">
|
||||||
|
<!-- 已设置: 概览卡 -->
|
||||||
|
<div class="fee-info-card">
|
||||||
|
<div class="fee-grid">
|
||||||
|
<div class="fee-field"><span class="f-label">服务费单号</span><span class="f-value mono">{{ feeExist.fee_code }}</span></div>
|
||||||
|
<div class="fee-field"><span class="f-label">状态</span>
|
||||||
|
<span class="f-value"><span class="st-pill" :style="{ color: statusPill(feeExist.status).color, background: statusPill(feeExist.status).bg }"><span class="st-dot"></span>{{ FEE_STATUS[feeExist.status] || feeExist.status }}</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="fee-field"><span class="f-label">计费方式</span><span class="f-value">{{ feeExist.fee_type === 1 ? '按订单金额比例 ' + feeExist.fee_rate + '%' : '固定金额' }}</span></div>
|
||||||
|
<div class="fee-field"><span class="f-label">设置时间</span><span class="f-value">{{ String(feeExist.create_time || '').replace('T', ' ').slice(0, 19) }}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="fee-progress">
|
||||||
|
<div class="fp-row">
|
||||||
|
<span>已收款</span>
|
||||||
|
<b>¥{{ Number(feeExist.paid_money || 0).toFixed(2) }}</b>
|
||||||
|
<span class="fp-sep">/</span>
|
||||||
|
<span>服务费总额</span>
|
||||||
|
<b class="fp-total">¥{{ Number(feeExist.fee_money).toFixed(2) }}</b>
|
||||||
|
</div>
|
||||||
|
<div class="fp-bar"><div class="fp-bar-in" :style="{ width: Math.min(100, Number(feeExist.fee_money) ? Number(feeExist.paid_money || 0) / Number(feeExist.fee_money) * 100 : 0) + '%' }"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fee-section-title">付款期次<span class="fee-section-sub">跟随订单付款方式拆分,供应商向平台主体转账后由运营确认</span></div>
|
||||||
|
<el-table :data="feeExist.periods || []" size="small" border>
|
||||||
|
<el-table-column prop="period_num" label="期次" width="55" align="center" />
|
||||||
|
<el-table-column label="付款节点" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span style="font-weight:600">{{ row.period_name || '第' + row.period_num + '期' }}</span>
|
||||||
|
<span v-if="row.period_ratio != null" class="fee-ratio">{{ Number(row.period_ratio) }}%</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="金额" width="110" align="right">
|
||||||
|
<template #default="{ row }">¥{{ Number(row.pay_money).toFixed(2) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.pay_status === 1 && row.node_unlocked === 0" type="info" size="small" effect="plain">🔒 未到节点</el-tag>
|
||||||
|
<el-tag v-else :type="row.pay_status === 3 ? 'success' : row.pay_status === 2 ? 'warning' : 'info'" size="small">{{ PERIOD_STATUS[row.pay_status] || row.pay_status }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="凭证/实收" min-width="130">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.pay_status === 3">实收 ¥{{ Number(row.received_money).toFixed(2) }}({{ (row.pay_date || '').slice(0, 10) }})</span>
|
||||||
|
<el-image v-else-if="row.images" :src="'/api/other/file/preview?url=' + encodeURIComponent(String(row.images).split(',')[0])" :preview-src-list="String(row.images).split(',').map((u: string) => '/api/other/file/preview?url=' + encodeURIComponent(u))" fit="cover" style="width:36px;height:36px;border-radius:4px" />
|
||||||
|
<span v-else>—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="feeRow">
|
||||||
|
<!-- 未设置: 订单信息卡 -->
|
||||||
|
<div class="fee-info-card">
|
||||||
|
<div class="fee-grid">
|
||||||
|
<div class="fee-field"><span class="f-label">订单号</span><span class="f-value mono">{{ feeRow.supplierOrderCode }}</span></div>
|
||||||
|
<div class="fee-field"><span class="f-label">供应商(付款方)</span><span class="f-value">{{ feeRow.supplierName }}</span></div>
|
||||||
|
<div class="fee-field"><span class="f-label">订单金额</span><span class="f-value f-strong">¥{{ Number(feeRow.totalAmount).toFixed(2) }}</span></div>
|
||||||
|
<div class="fee-field"><span class="f-label">收款方</span><span class="f-value">郑州巨变网络科技有限公司<span class="f-tag">平台主体</span></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 计费方式选择卡 -->
|
||||||
|
<div class="fee-section-title">计费方式</div>
|
||||||
|
<div class="fee-type-cards">
|
||||||
|
<div :class="['fee-type-card', { active: feeForm.feeType === 1 }]" @click="feeForm.feeType = 1">
|
||||||
|
<div class="ftc-head"><span class="ftc-icon">%</span><span class="ftc-check">✓</span></div>
|
||||||
|
<div class="ftc-title">按订单金额比例</div>
|
||||||
|
<div class="ftc-desc">订单金额 × 比例,随订单规模浮动</div>
|
||||||
|
</div>
|
||||||
|
<div :class="['fee-type-card', { active: feeForm.feeType === 2 }]" @click="feeForm.feeType = 2">
|
||||||
|
<div class="ftc-head"><span class="ftc-icon">¥</span><span class="ftc-check">✓</span></div>
|
||||||
|
<div class="ftc-title">固定金额</div>
|
||||||
|
<div class="ftc-desc">一次性定额,与订单金额无关</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 输入 -->
|
||||||
|
<div class="fee-section-title">{{ feeForm.feeType === 1 ? '收费比例' : '服务费金额' }}</div>
|
||||||
|
<div class="fee-input-row">
|
||||||
|
<template v-if="feeForm.feeType === 1">
|
||||||
|
<el-input-number v-model="feeForm.feeRate" :min="0.01" :max="100" :precision="2" :step="0.5" controls-position="right" style="width:170px" />
|
||||||
|
<span class="fee-unit">%</span>
|
||||||
|
<span class="fee-hint">订单金额 ¥{{ Number(feeRow.totalAmount).toFixed(2) }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<el-input-number v-model="feeForm.fixedMoney" :min="0.01" :precision="2" :step="100" controls-position="right" style="width:200px" />
|
||||||
|
<span class="fee-unit">元</span>
|
||||||
|
<span class="fee-hint">不可超过订单金额</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 结果卡 -->
|
||||||
|
<div class="fee-result">
|
||||||
|
<div class="fr-left">
|
||||||
|
<div class="fr-cap">服务费金额</div>
|
||||||
|
<div class="fr-val">¥{{ feePreview }}</div>
|
||||||
|
<div class="fr-calc">{{ feeForm.feeType === 1 ? `¥${Number(feeRow.totalAmount).toFixed(2)} × ${Number(feeForm.feeRate || 0)}%` : '一次性定额收取' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="fr-right">
|
||||||
|
<div class="fr-step" v-for="(st, si) in ['生成应付款(按订单期次拆分)', '供应商向平台主体转账并上传凭证', '运营确认收款,期次逐笔核销'] as any" :key="si">
|
||||||
|
<span class="frs-dot">{{ si + 1 }}</span>{{ st }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<template v-if="feeExist">
|
||||||
|
<el-button @click="feeDialog = false">关闭</el-button>
|
||||||
|
<el-button v-if="feeExist.status !== 3 && feeExist.status !== 4" type="danger" plain @click="onFeeCancelExist">作废重设</el-button>
|
||||||
|
<el-button type="primary" @click="router.push('/order/servicefee')">前往服务费管理</el-button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<el-button @click="feeDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="feeSubmitting" @click="onFeeSubmit">生成应付款</el-button>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.rr-page { max-width: 1500px; }
|
.rr-page { max-width: 1500px; }
|
||||||
|
/* 表头与数据统一居中 (覆盖 DataTable inline 的表头居中/数据左对齐错位, 2026-10-16) */
|
||||||
|
.rr-list-card :deep(.el-table th.el-table__cell),
|
||||||
|
.rr-list-card :deep(.el-table td.el-table__cell) {
|
||||||
|
text-align: center !important;
|
||||||
|
}
|
||||||
|
/* 操作列链接不换行不贴边 */
|
||||||
|
.rr-list-card :deep(.el-table td:last-child .el-button + .el-button) {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
.rr-muted { color: var(--ink-3); }
|
.rr-muted { color: var(--ink-3); }
|
||||||
|
.rr-order-no { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12.5px; color: var(--ink); }
|
||||||
.rr-amount { font-weight: 600; color: var(--primary); }
|
.rr-amount { font-weight: 600; color: var(--primary); }
|
||||||
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--ink-2); }
|
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--ink-2); }
|
||||||
|
|
||||||
|
/* ===== 平台服务费弹窗 ===== */
|
||||||
|
.fee-info-card {
|
||||||
|
background: var(--bg-2, #f8fafc);
|
||||||
|
border: 1px solid var(--border-1, #e5e7eb);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.fee-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 24px; }
|
||||||
|
.fee-field { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
|
||||||
|
.f-label { flex-shrink: 0; font-size: 12px; color: var(--ink-3); }
|
||||||
|
.f-value { font-size: 13px; color: var(--ink); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.f-value.mono { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; }
|
||||||
|
.f-strong { font-weight: 700; color: var(--primary); }
|
||||||
|
.f-tag { display: inline-block; margin-left: 6px; padding: 0 6px; font-size: 11px; font-weight: 600; color: var(--primary); background: var(--primary-soft, #dbeafe); border-radius: 4px; }
|
||||||
|
.fee-progress { margin-top: 12px; padding-top: 10px; border-top: 1px dashed var(--border-1, #e5e7eb); }
|
||||||
|
.fp-row { display: flex; align-items: baseline; gap: 6px; font-size: 12px; color: var(--ink-3); margin-bottom: 6px; }
|
||||||
|
.fp-row b { color: var(--ink); font-size: 14px; }
|
||||||
|
.fp-total { color: var(--primary) !important; }
|
||||||
|
.fp-sep { margin: 0 4px; }
|
||||||
|
.fp-bar { height: 8px; border-radius: 999px; background: rgba(0,0,0,.06); overflow: hidden; }
|
||||||
|
.fp-bar-in { height: 100%; border-radius: 999px; background: linear-gradient(90deg, var(--primary), var(--primary-dark, #1d4ed8)); transition: width .3s; }
|
||||||
|
.fee-section-title { font-size: 13px; font-weight: 700; color: var(--ink); margin: 16px 0 8px; }
|
||||||
|
.fee-section-sub { margin-left: 8px; font-size: 12px; font-weight: 400; color: var(--ink-3); }
|
||||||
|
.fee-type-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.fee-type-card {
|
||||||
|
position: relative; border: 1.5px solid var(--border-1, #e5e7eb); border-radius: 10px;
|
||||||
|
padding: 12px 14px; cursor: pointer; transition: all .15s; background: var(--bg-1, #fff);
|
||||||
|
}
|
||||||
|
.fee-type-card:hover { border-color: var(--primary); }
|
||||||
|
.fee-type-card.active { border-color: var(--primary); background: var(--primary-soft, #eff6ff); box-shadow: 0 0 0 1px var(--primary) inset; }
|
||||||
|
.ftc-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
|
||||||
|
.ftc-icon { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: 8px; background: var(--primary-soft, #dbeafe); color: var(--primary); font-size: 16px; font-weight: 800; }
|
||||||
|
.ftc-check { display: none; width: 18px; height: 18px; border-radius: 50%; background: var(--primary); color: #fff; font-size: 11px; text-align: center; line-height: 18px; }
|
||||||
|
.fee-type-card.active .ftc-check { display: block; }
|
||||||
|
.ftc-title { font-size: 13.5px; font-weight: 700; color: var(--ink); }
|
||||||
|
.ftc-desc { font-size: 12px; color: var(--ink-3); margin-top: 2px; }
|
||||||
|
.fee-input-row { display: flex; align-items: center; gap: 8px; padding: 4px 0 2px; }
|
||||||
|
.fee-unit { font-size: 14px; font-weight: 700; color: var(--ink); }
|
||||||
|
.fee-hint { font-size: 12px; color: var(--ink-3); margin-left: 6px; }
|
||||||
|
.fee-result {
|
||||||
|
margin-top: 14px; border-radius: 10px; padding: 14px 16px;
|
||||||
|
background: linear-gradient(135deg, var(--primary-soft, #eff6ff), rgba(255,255,255,0));
|
||||||
|
border: 1px solid color-mix(in srgb, var(--primary) 18%, transparent);
|
||||||
|
display: flex; gap: 20px; align-items: center;
|
||||||
|
}
|
||||||
|
.fr-left { flex-shrink: 0; min-width: 160px; }
|
||||||
|
.fr-cap { font-size: 12px; color: var(--ink-3); }
|
||||||
|
.fr-val { font-size: 26px; font-weight: 800; color: var(--primary); line-height: 1.2; }
|
||||||
|
.fr-calc { font-size: 12px; color: var(--ink-2); margin-top: 2px; }
|
||||||
|
.fr-right { flex: 1; display: flex; flex-direction: column; gap: 7px; border-left: 1px dashed var(--border-1, #e5e7eb); padding-left: 18px; }
|
||||||
|
.fr-step { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--ink-2); }
|
||||||
|
.frs-dot { flex-shrink: 0; width: 16px; height: 16px; border-radius: 50%; background: var(--primary); color: #fff; font-size: 10px; font-weight: 700; text-align: center; line-height: 16px; }
|
||||||
|
|
||||||
|
.fee-ratio { display: inline-block; margin-left: 6px; padding: 0 6px; font-size: 11px; font-weight: 700; color: var(--primary); background: var(--primary-soft, #dbeafe); border-radius: 4px; }
|
||||||
</style>
|
</style>
|
||||||
677
src/modules/order/ServiceFeeListView.vue
Normal file
677
src/modules/order/ServiceFeeListView.vue
Normal file
@ -0,0 +1,677 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 平台服务费(居间费)管理 — admin
|
||||||
|
*
|
||||||
|
* 数据源: /order/platformservicefee/page (order-server)
|
||||||
|
* 流程: 订单管理设置服务费 → 供应商工作台上传转账凭证 → 此处确认收款/驳回
|
||||||
|
* 操作: 确认收款(录入实收+查看凭证) / 驳回凭证 / 作废 / 期次明细展开
|
||||||
|
*/
|
||||||
|
import { ref, onMounted, reactive } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { httpGet, httpPost, http } from '@/utils/request'
|
||||||
|
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||||
|
import SearchBar from '@/components/SearchBar/SearchBar.vue'
|
||||||
|
import ListTabs from '@/components/ListTabs/ListTabs.vue'
|
||||||
|
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||||
|
import TableActions from '@/components/TableActions/TableActions.vue'
|
||||||
|
|
||||||
|
interface FeeRow {
|
||||||
|
id: number
|
||||||
|
fee_code: string
|
||||||
|
order_code: string
|
||||||
|
buyer_enterprise_name: string
|
||||||
|
supplier_enterprise_name: string
|
||||||
|
order_money: number
|
||||||
|
fee_type: number
|
||||||
|
fee_rate: number | null
|
||||||
|
fee_money: number
|
||||||
|
paid_money: number
|
||||||
|
status: number
|
||||||
|
period_count: number
|
||||||
|
paid_period_count: number
|
||||||
|
create_time: string
|
||||||
|
periods: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<number, string> = { 1: '待付款', 2: '部分已付', 3: '已付清', 4: '已作废' }
|
||||||
|
const STATUS_PILL: Record<number, { color: string; bg: string }> = {
|
||||||
|
3: { color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
|
||||||
|
4: { color: 'var(--st-lv4-fg)', bg: 'var(--st-lv4-bg)' }
|
||||||
|
}
|
||||||
|
const PERIOD_LABEL: Record<number, string> = { 1: '待支付', 2: '待确认', 3: '已支付' }
|
||||||
|
const STATUS_TABS = [
|
||||||
|
{ value: '', label: '全部' },
|
||||||
|
{ value: '1', label: '待付款' },
|
||||||
|
{ value: '2', label: '部分已付' },
|
||||||
|
{ value: '3', label: '已付清' },
|
||||||
|
{ value: '4', label: '已作废' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const list = ref<FeeRow[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const page = ref(1)
|
||||||
|
const limit = ref(10)
|
||||||
|
const filters = reactive({ orderCode: '', supplierName: '', status: '' })
|
||||||
|
const expandedRows = ref<number[]>([])
|
||||||
|
|
||||||
|
// 表格改原生 el-table 行展开 (2026-10-16: 期次清单原渲染在页面底部, 低分辨率屏看不到以为无反应)
|
||||||
|
const feeTableRef = ref()
|
||||||
|
const tabItems = STATUS_TABS.map(t => ({ key: t.value, label: t.label }))
|
||||||
|
|
||||||
|
function statusPill(status: number) {
|
||||||
|
return STATUS_PILL[status] || { color: 'var(--primary)', bg: 'var(--primary-soft, #dbeafe)' }
|
||||||
|
}
|
||||||
|
function feeTypeText(row: FeeRow) {
|
||||||
|
return row.fee_type === 1 ? `比例 ${row.fee_rate}%` : '固定金额'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const params: any = { page: page.value, limit: limit.value }
|
||||||
|
if (filters.orderCode) params.orderCode = filters.orderCode
|
||||||
|
if (filters.supplierName) params.supplierName = filters.supplierName
|
||||||
|
if (filters.status) params.status = filters.status
|
||||||
|
const r: any = await httpGet('/order/platformservicefee/page', params)
|
||||||
|
if (r?.sum) sum.value = r.sum
|
||||||
|
list.value = (r?.list || []).map((it: any) => ({ ...it, periods: [] }))
|
||||||
|
const t = r?.total
|
||||||
|
total.value = typeof t === 'string' ? parseInt(t, 10) : (Number(t) || 0)
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.msg || e?.message || '加载失败'
|
||||||
|
list.value = []
|
||||||
|
total.value = 0
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensurePeriods(row: FeeRow) {
|
||||||
|
if (!row.periods.length) {
|
||||||
|
try {
|
||||||
|
const d: any = await httpGet('/order/platformservicefee/detail', { orderCode: row.order_code })
|
||||||
|
row.periods = d?.periods || []
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || '期次加载失败') }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 点期次列/期次明细: 懒加载后触发行展开箭头
|
||||||
|
async function toggleExpand(row: FeeRow) {
|
||||||
|
await ensurePeriods(row)
|
||||||
|
feeTableRef.value?.toggleRowExpansion(row)
|
||||||
|
}
|
||||||
|
// 用户直接点行首展开箭头: 展开时懒加载期次
|
||||||
|
async function onExpandChange(row: FeeRow, expanded: FeeRow[]) {
|
||||||
|
expandedRows.value = expanded.map((r: any) => r.id)
|
||||||
|
if (expanded.includes(row)) await ensurePeriods(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 汇总统计 =====
|
||||||
|
const sum = ref<any>({ total_fee: 0, total_paid: 0, total_pending: 0, fee_count: 0 })
|
||||||
|
|
||||||
|
function onSearch() { page.value = 1; load() }
|
||||||
|
function onReset() { filters.orderCode = ''; filters.supplierName = ''; filters.status = ''; load() }
|
||||||
|
function onPageChange(p: number) { page.value = p; load() }
|
||||||
|
function onLimitChange(l: number) { limit.value = l; load() }
|
||||||
|
function onStatusTabChange(v: string) { filters.status = v; onSearch() }
|
||||||
|
|
||||||
|
// ===== 主 tab: 服务费列表 / 开票申请 =====
|
||||||
|
const mainTab = ref<'fee' | 'invoice'>('fee')
|
||||||
|
|
||||||
|
// ===== 开票申请(admin 处理) =====
|
||||||
|
const invList = ref<any[]>([])
|
||||||
|
const invTotal = ref(0)
|
||||||
|
const invPage = ref(1)
|
||||||
|
const invLimit = ref(10)
|
||||||
|
const invLoading = ref(false)
|
||||||
|
const invStatus = ref('')
|
||||||
|
const INV_LABEL: Record<number, string> = { 1: '待开票', 2: '已开票', 3: '已驳回' }
|
||||||
|
const invTabItems = [
|
||||||
|
{ value: '', label: '全部' },
|
||||||
|
{ value: '1', label: '待开票' },
|
||||||
|
{ value: '2', label: '已开票' },
|
||||||
|
{ value: '3', label: '已驳回' }
|
||||||
|
]
|
||||||
|
|
||||||
|
async function loadInvoices() {
|
||||||
|
invLoading.value = true
|
||||||
|
try {
|
||||||
|
const params: any = { page: invPage.value, limit: invLimit.value }
|
||||||
|
if (invStatus.value) params.status = invStatus.value
|
||||||
|
const r: any = await httpGet('/order/platformservicefee/invoiceAdminList', params)
|
||||||
|
invList.value = r?.list || []
|
||||||
|
invTotal.value = Number(r?.total || 0)
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || '开票申请加载失败') } finally { invLoading.value = false }
|
||||||
|
}
|
||||||
|
function onInvSearch() { invPage.value = 1; loadInvoices() }
|
||||||
|
function onInvPageChange(p: number) { invPage.value = p; loadInvoices() }
|
||||||
|
function onInvLimitChange(l: number) { invLimit.value = l; invPage.value = 1; loadInvoices() }
|
||||||
|
|
||||||
|
const invDialog = ref(false)
|
||||||
|
const invRow = ref<any>(null)
|
||||||
|
const invAction = ref<'approve' | 'reject'>('approve')
|
||||||
|
const invFile = ref<string[]>([])
|
||||||
|
const invUploading = ref(false)
|
||||||
|
const invDate = ref('')
|
||||||
|
const invRejectReason = ref('')
|
||||||
|
const invSubmitting = ref(false)
|
||||||
|
|
||||||
|
function openProcess(row: any, action: 'approve' | 'reject') {
|
||||||
|
invRow.value = row
|
||||||
|
invAction.value = action
|
||||||
|
invFile.value = []
|
||||||
|
invDate.value = new Date().toISOString().slice(0, 10)
|
||||||
|
invRejectReason.value = ''
|
||||||
|
invDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onInvFileChange(e: any) {
|
||||||
|
const files = Array.from(e.target?.files || [])
|
||||||
|
if (e.target) e.target.value = ''
|
||||||
|
for (const f of files) {
|
||||||
|
invUploading.value = true
|
||||||
|
try {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', f as File)
|
||||||
|
const res: any = await http.post('/sys/file/upload', fd, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
if (res?.data?.url) invFile.value.push(res.data.url)
|
||||||
|
else ElMessage.error(res?.msg || '上传失败')
|
||||||
|
} catch (e: any) { ElMessage.error(e?.message || '上传失败') } finally { invUploading.value = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitProcess() {
|
||||||
|
if (!invRow.value) return
|
||||||
|
invSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await httpPost('/order/platformservicefee/invoiceProcess', {
|
||||||
|
id: invRow.value.id,
|
||||||
|
action: invAction.value,
|
||||||
|
invoiceImage: invFile.value.join(','),
|
||||||
|
invoiceDate: invDate.value,
|
||||||
|
rejectReason: invRejectReason.value
|
||||||
|
})
|
||||||
|
ElMessage.success(invAction.value === 'approve' ? '已开票并上传发票' : '已驳回')
|
||||||
|
invDialog.value = false
|
||||||
|
loadInvoices()
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') } finally { invSubmitting.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 确认收款 =====
|
||||||
|
const confirmDialog = ref(false)
|
||||||
|
const confirmPeriod = ref<any>(null)
|
||||||
|
const confirmRow = ref<FeeRow | null>(null)
|
||||||
|
const receivedMoney = ref('')
|
||||||
|
const confirmSubmitting = ref(false)
|
||||||
|
|
||||||
|
function openConfirm(row: FeeRow, period: any) {
|
||||||
|
confirmRow.value = row
|
||||||
|
confirmPeriod.value = period
|
||||||
|
receivedMoney.value = String(period.pay_money)
|
||||||
|
confirmDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const payWayLabel = (p: any) => (Number(p?.pay_way) === 2 ? '线上支付' : Number(p?.pay_way) === 1 ? '线下转账' : '—')
|
||||||
|
|
||||||
|
async function onRejectFromDialog() {
|
||||||
|
if (!confirmRow.value || !confirmPeriod.value) return
|
||||||
|
let reason = ''
|
||||||
|
try {
|
||||||
|
const r = await ElMessageBox.prompt('驳回后该期次回到待支付, 凭证将被清除', '驳回凭证', {
|
||||||
|
inputPlaceholder: '驳回原因(选填)', type: 'warning'
|
||||||
|
})
|
||||||
|
reason = r.value || ''
|
||||||
|
} catch { return }
|
||||||
|
try {
|
||||||
|
await httpPost('/order/platformservicefee/reject', { payId: confirmPeriod.value.id, reason })
|
||||||
|
ElMessage.success('已驳回, 该期次已回到待支付')
|
||||||
|
confirmDialog.value = false
|
||||||
|
await load()
|
||||||
|
expandedRows.value = []
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '驳回失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillFullAmount() {
|
||||||
|
if (confirmPeriod.value) receivedMoney.value = String(confirmPeriod.value.pay_money)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitConfirm() {
|
||||||
|
const m = Number(receivedMoney.value)
|
||||||
|
if (!(m > 0)) { ElMessage.warning('请输入实收金额'); return }
|
||||||
|
confirmSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await httpPost('/order/platformservicefee/confirm', { payId: confirmPeriod.value.id, receivedMoney: m })
|
||||||
|
ElMessage.success('确认收款成功')
|
||||||
|
confirmDialog.value = false
|
||||||
|
await load()
|
||||||
|
expandedRows.value = []
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.msg || e?.message || '操作失败')
|
||||||
|
} finally { confirmSubmitting.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 驳回 =====
|
||||||
|
async function onReject(row: FeeRow, period: any) {
|
||||||
|
let reason = ''
|
||||||
|
try {
|
||||||
|
const r = await ElMessageBox.prompt('驳回后该期次回到待支付, 凭证将被清除', '驳回凭证', {
|
||||||
|
inputPlaceholder: '驳回原因(选填)', type: 'warning'
|
||||||
|
})
|
||||||
|
reason = r.value || ''
|
||||||
|
} catch { return }
|
||||||
|
try {
|
||||||
|
await httpPost('/order/platformservicefee/reject', { payId: period.id, reason })
|
||||||
|
ElMessage.success('已驳回')
|
||||||
|
row.periods = []
|
||||||
|
expandedRows.value = []
|
||||||
|
await load()
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '驳回失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 作废 =====
|
||||||
|
async function onCancel(row: FeeRow) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认作废 ${row.fee_code}? 仅允许作废无已收款期次的服务费`, '作废确认', { type: 'warning' })
|
||||||
|
} catch { return }
|
||||||
|
try {
|
||||||
|
await httpPost('/order/platformservicefee/cancel', { id: row.id })
|
||||||
|
ElMessage.success('已作废')
|
||||||
|
load()
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '作废失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewUrl(u: string) {
|
||||||
|
return '/api/other/file/preview?url=' + encodeURIComponent(u)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rr-page">
|
||||||
|
<PageHeader title="平台服务费管理" :sub="`供应商应付平台居间费 · 共 ${total} 条`" />
|
||||||
|
|
||||||
|
<div class="sum-grid">
|
||||||
|
<div class="sum-card">
|
||||||
|
<div class="sum-label">应付服务费总额</div>
|
||||||
|
<div class="sum-value" style="color:var(--primary)">¥{{ Number(sum.total_fee || 0).toFixed(2) }}</div>
|
||||||
|
<div class="sum-sub">共 {{ Number(sum.fee_count || 0) }} 笔有效服务费</div>
|
||||||
|
</div>
|
||||||
|
<div class="sum-card">
|
||||||
|
<div class="sum-label">已确认收款</div>
|
||||||
|
<div class="sum-value" style="color:var(--el-color-success,#67c23a)">¥{{ Number(sum.total_paid || 0).toFixed(2) }}</div>
|
||||||
|
<div class="sum-sub">含部分确认期次</div>
|
||||||
|
</div>
|
||||||
|
<div class="sum-card">
|
||||||
|
<div class="sum-label">待收款</div>
|
||||||
|
<div class="sum-value" style="color:#e6a23c">¥{{ Number(sum.total_pending || 0).toFixed(2) }}</div>
|
||||||
|
<div class="sum-sub">总额 − 已确认收款</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main-tabs">
|
||||||
|
<button class="main-tab" :class="{ active: mainTab === 'fee' }" @click="mainTab = 'fee'">服务费列表</button>
|
||||||
|
<button class="main-tab" :class="{ active: mainTab === 'invoice' }" @click="mainTab = 'invoice'; loadInvoices()">开票申请</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="mainTab === 'fee'">
|
||||||
|
<ListTabs v-model="filters.status" :items="tabItems" @change="onStatusTabChange" />
|
||||||
|
|
||||||
|
<SearchBar
|
||||||
|
:fields="[
|
||||||
|
{ key: 'orderCode', label: '订单号', placeholder: 'OSP 订单号' },
|
||||||
|
{ key: 'supplierName', label: '供应商', placeholder: '供应商企业名' }
|
||||||
|
]"
|
||||||
|
v-model:filters="filters"
|
||||||
|
@search="onSearch"
|
||||||
|
@reset="onReset"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="error" class="rr-empty-tip" style="color:#ef4444">{{ error }} <el-link type="primary" @click="load">重试</el-link></div>
|
||||||
|
<el-table ref="feeTableRef" :data="list" row-key="id" v-loading="loading" border
|
||||||
|
@expand-change="onExpandChange" style="width:100%">
|
||||||
|
<el-table-column type="expand">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="period-block" style="margin:0 12px 12px">
|
||||||
|
<div class="period-title">{{ row.fee_code }} · 付款期次(跟随订单付款方式拆分)</div>
|
||||||
|
<el-table :data="row.periods" size="small" border>
|
||||||
|
<el-table-column prop="period_num" label="期次" width="60" align="center" />
|
||||||
|
<el-table-column label="付款节点" min-width="150">
|
||||||
|
<template #default="{ row: p }">
|
||||||
|
<span style="font-weight:600">{{ p.period_name || '第' + p.period_num + '期' }}</span>
|
||||||
|
<span v-if="p.period_ratio != null" class="fee-ratio">{{ Number(p.period_ratio) }}%</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="应付金额" width="110" align="right">
|
||||||
|
<template #default="{ row: p }">¥{{ Number(p.pay_money).toFixed(2) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="110" align="center">
|
||||||
|
<template #default="{ row: p }">
|
||||||
|
<el-tag v-if="p.pay_status === 1 && Number(p.node_unlocked) === 0" type="info" size="small" effect="plain">🔒 未到节点</el-tag>
|
||||||
|
<el-tag v-else-if="p.pay_status === 2" type="warning" size="small" effect="dark">待确认收款</el-tag>
|
||||||
|
<el-tag v-else :type="p.pay_status === 3 ? 'success' : 'info'" size="small">{{ PERIOD_LABEL[p.pay_status] }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="转账凭证" min-width="150">
|
||||||
|
<template #default="{ row: p }">
|
||||||
|
<template v-if="p.images">
|
||||||
|
<el-image
|
||||||
|
v-for="(u, i) in String(p.images).split(',').filter(Boolean)"
|
||||||
|
:key="i"
|
||||||
|
:src="previewUrl(u)"
|
||||||
|
:preview-src-list="String(p.images).split(',').filter(Boolean).map(previewUrl)"
|
||||||
|
fit="cover"
|
||||||
|
style="width:38px;height:38px;border-radius:4px;margin-right:4px"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<span v-else class="rr-muted">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="付款方式/填报" min-width="120" align="center">
|
||||||
|
<template #default="{ row: p }">
|
||||||
|
<div v-if="p.pay_way" style="font-size:12px;font-weight:600">{{ p.pay_way === 1 ? '线下转账' : '线上支付' }}</div>
|
||||||
|
<div v-if="p.submit_money != null" class="rr-muted" style="font-size:11px">填报 ¥{{ Number(p.submit_money).toFixed(2) }}</div>
|
||||||
|
<span v-if="!p.pay_way && p.submit_money == null" class="rr-muted">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="实收/驳回" min-width="140">
|
||||||
|
<template #default="{ row: p }">
|
||||||
|
<div v-if="p.pay_status === 3">实收 ¥{{ Number(p.received_money).toFixed(2) }} · {{ String(p.pay_date || '').slice(0, 10) }}</div>
|
||||||
|
<div v-else-if="p.reject_reason" class="rr-muted">驳回: {{ p.reject_reason }}</div>
|
||||||
|
<span v-else class="rr-muted">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="150" align="center">
|
||||||
|
<template #default="{ row: p }">
|
||||||
|
<template v-if="p.pay_status === 2">
|
||||||
|
<el-button type="primary" size="small" @click="openConfirm(row, p)">确认收款</el-button>
|
||||||
|
</template>
|
||||||
|
<span v-else-if="p.pay_status === 1 && Number(p.node_unlocked) === 0" class="rr-muted" style="font-size:11px">待订单进入该节点</span>
|
||||||
|
<span v-else class="rr-muted">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="fee_code" label="服务费单号" width="210">
|
||||||
|
<template #default="{ row }"><span class="rr-order-no">{{ row.fee_code }}</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="order_code" label="订单号" width="210">
|
||||||
|
<template #default="{ row }"><span class="rr-order-no">{{ row.order_code }}</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="supplier_enterprise_name" label="供应商(付款方)" min-width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column label="订单金额" width="110" align="right">
|
||||||
|
<template #default="{ row }">¥{{ Number(row.order_money).toFixed(2) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="服务费" width="110" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="rr-fee">¥{{ Number(row.fee_money).toFixed(2) }}</span>
|
||||||
|
<div class="rr-muted" style="font-size:11px">{{ feeTypeText(row) }}</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="已收款" width="110" align="right">
|
||||||
|
<template #default="{ row }">¥{{ Number(row.paid_money || 0).toFixed(2) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="期次" width="110" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link type="primary" :underline="false" @click="toggleExpand(row)">
|
||||||
|
{{ row.paid_period_count }}/{{ row.period_count }} 期
|
||||||
|
<span style="font-size:11px">{{ expandedRows.includes(row.id) ? '▲' : '▼' }}</span>
|
||||||
|
</el-link>
|
||||||
|
<div v-if="Number(row.pending_period_count) > 0" style="margin-top:2px">
|
||||||
|
<el-link type="danger" :underline="false" style="font-size:11px;font-weight:600" @click="toggleExpand(row)">⚠ {{ row.pending_period_count }} 期待确认收款</el-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="st-pill" :style="{ color: statusPill(row.status).color, background: statusPill(row.status).bg }">
|
||||||
|
<span class="st-dot"></span>{{ STATUS_LABEL[row.status] || row.status }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="设置时间" width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="rr-time">{{ String(row.create_time || '').replace('T', ' ').slice(0, 19) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="150" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<TableActions
|
||||||
|
:row="row"
|
||||||
|
:actions="[
|
||||||
|
{ label: '期次明细', type: 'primary', onClick: (r: any) => toggleExpand(r) },
|
||||||
|
...(row.status === 1 || row.status === 2 ? [{ label: '作废', type: 'danger' as const, onClick: (r: any) => onCancel(r) }] : [])
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div style="display:flex;justify-content:flex-end;padding:14px 4px 4px">
|
||||||
|
<el-pagination background layout="total, sizes, prev, pager, next, jumper" :total="total"
|
||||||
|
:current-page="page" :page-size="limit" :page-sizes="[10, 20, 50]"
|
||||||
|
@current-change="(p: number) => { page = p; load() }"
|
||||||
|
@size-change="(l: number) => { limit = l; page = 1; load() }" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="main-tabs" style="margin-bottom:0">
|
||||||
|
<button v-for="t in invTabItems" :key="t.value" class="main-tab" :class="{ active: invStatus === t.value }" @click="invStatus = t.value; onInvSearch()">{{ t.label }}</button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="invList" v-loading="invLoading" size="default" border style="margin-top:12px">
|
||||||
|
<el-table-column prop="create_time" label="申请时间" width="150">
|
||||||
|
<template #default="{ row: r }">{{ String(r.create_time || '').slice(0, 19).replace('T', ' ') }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="fee_code" label="服务费单号" width="185" />
|
||||||
|
<el-table-column prop="order_code" label="订单号" width="185" />
|
||||||
|
<el-table-column prop="supplier_enterprise_name" label="供应商(付款方)" min-width="150" />
|
||||||
|
<el-table-column label="开票金额" width="110" align="right">
|
||||||
|
<template #default="{ row: r }">¥{{ Number(r.apply_money).toFixed(2) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="invoice_body" label="发票抬头" min-width="170" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="invoice_tax" label="税号" width="150" show-overflow-tooltip />
|
||||||
|
<el-table-column label="状态" width="90" align="center">
|
||||||
|
<template #default="{ row: r }">
|
||||||
|
<el-tag :type="r.status === 2 ? 'success' : r.status === 3 ? 'danger' : 'warning'" size="small">{{ INV_LABEL[r.status] || r.status }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="发票" width="120">
|
||||||
|
<template #default="{ row: r }">
|
||||||
|
<template v-if="r.status === 2 && r.invoice_image">
|
||||||
|
<template v-for="(u, i) in String(r.invoice_image).split(',').filter(Boolean)" :key="i">
|
||||||
|
<el-image v-if="/\.(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/i.test(u)" :src="previewUrl(u)" :preview-src-list="String(r.invoice_image).split(',').filter(Boolean).map(previewUrl)" fit="cover" style="width:34px;height:34px;border-radius:4px;margin-right:4px" />
|
||||||
|
<el-link v-else type="primary" :underline="false" :href="previewUrl(u)" target="_blank" style="font-size:12px;margin-right:6px">发票PDF</el-link>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<span v-else class="rr-muted">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="110" align="center">
|
||||||
|
<template #default="{ row: r }">
|
||||||
|
<template v-if="r.status === 1">
|
||||||
|
<el-button type="primary" size="small" @click="openProcess(r, 'approve')">开票</el-button>
|
||||||
|
<el-button type="danger" plain size="small" @click="openProcess(r, 'reject')">驳回</el-button>
|
||||||
|
</template>
|
||||||
|
<span v-else class="rr-muted">已处理</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div style="display:flex;justify-content:flex-end;padding:12px 0">
|
||||||
|
<el-pagination background layout="total, prev, pager, next, sizes" :total="invTotal" :page-size="invLimit" :current-page="invPage" @current-change="onInvPageChange" @size-change="onInvLimitChange" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 开票处理弹窗 -->
|
||||||
|
<el-dialog v-model="invDialog" :title="invAction === 'approve' ? '开票并上传发票' : '驳回开票申请'" width="480px" destroy-on-close>
|
||||||
|
<div v-if="invRow" style="margin-bottom:12px;color:var(--ink-2);font-size:13px">
|
||||||
|
{{ invRow.supplier_enterprise_name }} · 服务费单 {{ invRow.fee_code }} · 申请开票 <b style="color:var(--primary)">¥{{ Number(invRow.apply_money).toFixed(2) }}</b>
|
||||||
|
<div style="margin-top:4px">抬头: {{ invRow.invoice_body }}<template v-if="invRow.invoice_tax">(税号 {{ invRow.invoice_tax }})</template></div>
|
||||||
|
</div>
|
||||||
|
<template v-if="invAction === 'approve'">
|
||||||
|
<el-form label-width="90px">
|
||||||
|
<el-form-item label="发票文件">
|
||||||
|
<label style="display:inline-flex;align-items:center;gap:6px;padding:6px 14px;font-size:12px;font-weight:600;border:1px dashed var(--border-1,#dcdfe6);border-radius:6px;cursor:pointer">
|
||||||
|
{{ invUploading ? '上传中...' : '选择文件' }}
|
||||||
|
<input type="file" accept="image/*,.pdf" multiple class="hidden-input" @change="onInvFileChange" />
|
||||||
|
</label>
|
||||||
|
<div v-if="invFile.length" style="margin-top:8px;display:flex;gap:6px;flex-wrap:wrap">
|
||||||
|
<template v-for="(u, i) in invFile" :key="i">
|
||||||
|
<el-image v-if="/\.(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/i.test(u)" :src="previewUrl(u)" :preview-src-list="invFile.map(previewUrl)" fit="cover" style="width:56px;height:56px;border-radius:6px" />
|
||||||
|
<el-tag v-else type="danger" size="small">PDF已上传</el-tag>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="发票日期">
|
||||||
|
<el-date-picker v-model="invDate" type="date" value-format="YYYY-MM-DD" style="width:180px" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<el-input v-model="invRejectReason" type="textarea" :rows="3" placeholder="驳回原因(必填)" />
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="invDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="invSubmitting" @click="submitProcess">{{ invAction === 'approve' ? '确认开票' : '确认驳回' }}</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 确认收款弹窗 -->
|
||||||
|
<el-dialog v-model="confirmDialog" title="确认收款" width="560px" destroy-on-close>
|
||||||
|
<div v-if="confirmPeriod" class="confirm-wrap">
|
||||||
|
<!-- 期次信息卡 -->
|
||||||
|
<div class="cfm-head">
|
||||||
|
<div class="cfm-row1">
|
||||||
|
<span class="cfm-ent">{{ confirmRow?.supplier_enterprise_name }}</span>
|
||||||
|
<el-tag type="warning" size="small" effect="dark">待确认收款</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="cfm-row2">
|
||||||
|
<span>第 {{ confirmPeriod.period_num }} 期 · {{ confirmPeriod.period_name || '第' + confirmPeriod.period_num + '期' }}</span>
|
||||||
|
<span v-if="confirmPeriod.period_ratio != null" class="fee-ratio">占{{ Number(confirmPeriod.period_ratio) }}%</span>
|
||||||
|
<span class="cfm-amount">应付 <b>¥{{ Number(confirmPeriod.pay_money).toFixed(2) }}</b></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 付款信息 -->
|
||||||
|
<div class="cfm-payinfo">
|
||||||
|
<div class="cfm-payitem">
|
||||||
|
<div class="cfm-paylabel">付款方式</div>
|
||||||
|
<div class="cfm-payval">{{ payWayLabel(confirmPeriod) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="cfm-payitem">
|
||||||
|
<div class="cfm-paylabel">供应商填报金额</div>
|
||||||
|
<div class="cfm-payval">{{ confirmPeriod.submit_money != null ? '¥' + Number(confirmPeriod.submit_money).toFixed(2) : '—' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="cfm-payitem">
|
||||||
|
<div class="cfm-paylabel">服务费单</div>
|
||||||
|
<div class="cfm-payval" style="font-size:12px">{{ confirmRow?.fee_code }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 凭证 -->
|
||||||
|
<div v-if="confirmPeriod?.images" class="cfm-vouchers">
|
||||||
|
<div class="cfm-voulabel">供应商转账凭证(点击放大)</div>
|
||||||
|
<div class="cfm-vougrid">
|
||||||
|
<template v-for="(u, i) in String(confirmPeriod.images).split(',').filter(Boolean)" :key="i">
|
||||||
|
<el-image
|
||||||
|
v-if="/\.(png|jpe?g|gif|webp|bmp|svg)(\?.*)?$/i.test(u)"
|
||||||
|
:src="previewUrl(u)"
|
||||||
|
:preview-src-list="String(confirmPeriod.images).split(',').filter(Boolean).map(previewUrl)"
|
||||||
|
fit="cover"
|
||||||
|
class="cfm-vouimg"
|
||||||
|
/>
|
||||||
|
<el-link v-else type="danger" :underline="false" :href="previewUrl(u)" target="_blank" class="cfm-voupdf">凭证PDF</el-link>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 实收金额 -->
|
||||||
|
<div class="cfm-money">
|
||||||
|
<span class="cfm-paylabel">实收金额</span>
|
||||||
|
<el-input-number v-model="receivedMoney" :min="0.01" :precision="2" :step="100" style="width:180px" />
|
||||||
|
<span style="color:var(--ink-3)">元</span>
|
||||||
|
<el-button size="small" text type="primary" @click="fillFullAmount">按应付全额 ¥{{ Number(confirmPeriod.pay_money).toFixed(2) }}</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="confirmDialog = false">取消</el-button>
|
||||||
|
<el-button type="danger" plain :loading="confirmSubmitting" @click="onRejectFromDialog">驳回凭证</el-button>
|
||||||
|
<el-button type="primary" :loading="confirmSubmitting" @click="submitConfirm">确认收款</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.rr-page { max-width: 1500px; }
|
||||||
|
|
||||||
|
/* ===== 顶部汇总卡 ===== */
|
||||||
|
.sum-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin-bottom: 16px; }
|
||||||
|
.sum-card {
|
||||||
|
background: #fff; border: 1px solid var(--border-1, #e5e7eb); border-radius: 10px;
|
||||||
|
padding: 14px 18px; box-shadow: 0 1px 2px rgba(0,0,0,.03);
|
||||||
|
}
|
||||||
|
.sum-label { font-size: 12px; color: var(--ink-3); margin-bottom: 6px; }
|
||||||
|
.sum-value { font-size: 24px; font-weight: 800; font-variant-numeric: tabular-nums; line-height: 1.2; }
|
||||||
|
.sum-sub { font-size: 11px; color: var(--ink-3); margin-top: 4px; }
|
||||||
|
|
||||||
|
/* ===== 确认收款弹窗 ===== */
|
||||||
|
.confirm-wrap { padding: 0 2px; }
|
||||||
|
.cfm-head {
|
||||||
|
background: linear-gradient(135deg, rgba(37,99,235,.06), rgba(37,99,235,.02));
|
||||||
|
border: 1px solid rgba(37,99,235,.18); border-radius: 10px; padding: 12px 16px; margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.cfm-row1 { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
|
||||||
|
.cfm-ent { font-size: 14px; font-weight: 700; color: var(--ink); }
|
||||||
|
.cfm-row2 { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--ink-2); }
|
||||||
|
.cfm-amount { margin-left: auto; font-size: 13px; color: var(--ink-2); }
|
||||||
|
.cfm-amount b { font-size: 18px; color: var(--primary); font-variant-numeric: tabular-nums; }
|
||||||
|
.cfm-payinfo { display: flex; gap: 12px; margin-bottom: 12px; }
|
||||||
|
.cfm-payitem {
|
||||||
|
flex: 1; background: var(--bg-1, #fafafa); border: 1px solid var(--border-1, #ececec);
|
||||||
|
border-radius: 8px; padding: 8px 12px;
|
||||||
|
}
|
||||||
|
.cfm-paylabel { font-size: 11px; color: var(--ink-3); margin-bottom: 3px; }
|
||||||
|
.cfm-payval { font-size: 13px; font-weight: 600; color: var(--ink); }
|
||||||
|
.cfm-vouchers { margin-bottom: 12px; }
|
||||||
|
.cfm-voulabel { font-size: 12px; color: var(--ink-3); margin-bottom: 8px; }
|
||||||
|
.cfm-vougrid { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.cfm-vouimg { width: 84px; height: 84px; border-radius: 8px; border: 1px solid var(--border-1, #ececec); cursor: zoom-in; }
|
||||||
|
.cfm-voupdf {
|
||||||
|
width: 84px; height: 84px; display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
border: 1px dashed var(--el-color-danger,#f56c6c); border-radius: 8px; font-size: 12px; background: #fef0f0;
|
||||||
|
}
|
||||||
|
.cfm-money { display: flex; align-items: center; gap: 10px; padding: 12px 0 2px; border-top: 1px solid var(--border-1, #f0f0f0); }
|
||||||
|
.cfm-money .cfm-paylabel { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.main-tabs { display: flex; gap: 8px; margin-bottom: 14px; }
|
||||||
|
.main-tab {
|
||||||
|
padding: 7px 18px; font-size: 13px; font-weight: 600; border-radius: 18px;
|
||||||
|
border: 1px solid var(--border-1, #dcdfe6); background: #fff; color: var(--ink-2);
|
||||||
|
cursor: pointer; transition: all .15s;
|
||||||
|
}
|
||||||
|
.main-tab:hover { color: var(--primary); border-color: var(--primary); }
|
||||||
|
.main-tab.active { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||||||
|
.hidden-input { display: none; }
|
||||||
|
.rr-muted { color: var(--ink-3); }
|
||||||
|
.rr-order-no { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12.5px; color: var(--ink); }
|
||||||
|
.rr-fee { font-weight: 600; color: var(--primary); }
|
||||||
|
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--ink-2); }
|
||||||
|
.period-block {
|
||||||
|
border: 1px solid var(--border-1, #e5e7eb);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin: -6px 0 14px;
|
||||||
|
background: var(--bg-1, #fafafa);
|
||||||
|
}
|
||||||
|
.period-title { font-weight: 600; margin-bottom: 8px; font-size: 13px; }
|
||||||
|
.st-pill {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 500;
|
||||||
|
}
|
||||||
|
.st-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||||
|
|
||||||
|
.fee-ratio { display: inline-block; margin-left: 6px; padding: 0 6px; font-size: 11px; font-weight: 700; color: var(--primary); background: var(--primary-soft, #dbeafe); border-radius: 4px; }
|
||||||
|
</style>
|
||||||
@ -1,55 +1,303 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* F006 行业/资讯分类 - P0 真 view (共用组件, props.type 区分)
|
* F006 行业/资讯分类 - 树形增删改完整版 (2026-08-18, 2026-08-22 整改数据源 + 去统计卡 + 加排序按钮)
|
||||||
* 走 /other/category/categoryDeepPageList (type: 1=行业 3=资讯分类)
|
*
|
||||||
* 2026-08-07 探测: type=1 total=27, type=3 total=4
|
* 后端端点 (ApiCategoryController /other/category):
|
||||||
* 字段: id/name/level/pid/sort/status/createDate/children
|
* GET categoryDeepPageList?type= 平铺 + 含 status + createDate 字段 (前端按 pid 组装树)
|
||||||
|
* POST save 新增 (body: name/type/level/pid/sort/status)
|
||||||
|
* POST update 修改 (body, 同 save)
|
||||||
|
* GET delete?id= 删除 (递归删子级)
|
||||||
|
* POST enabled?id= 启用/禁用
|
||||||
|
* GET moveCategoryNew?id=&type= 排序上(type=1)/下(type=2)移
|
||||||
*/
|
*/
|
||||||
import { onMounted, ref, reactive } from 'vue'
|
import { onMounted, ref, reactive, computed } from 'vue'
|
||||||
import { httpGet } from '@/utils/request'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
|
import { httpGet, httpPost } from '@/utils/request'
|
||||||
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||||
|
import SearchBar from '@/components/SearchBar/SearchBar.vue'
|
||||||
|
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||||
|
import TableActions from '@/components/TableActions/TableActions.vue'
|
||||||
|
|
||||||
interface Category { id: string; name: string; level: number; pid: string; sort: number; status: number; createDate: string; children?: Category[] }
|
interface Category {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
level: number
|
||||||
|
pid: string
|
||||||
|
sort: number
|
||||||
|
status: number
|
||||||
|
createDate?: string
|
||||||
|
children?: Category[]
|
||||||
|
// 运行时挂载, 用于上移/下移时定位兄弟节点位置
|
||||||
|
_parentArray?: Category[]
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps<{ type?: number; title?: string }>()
|
const props = defineProps<{ type?: number; title?: string }>()
|
||||||
const TITLES: Record<number, string> = { 1: '行业管理', 3: '资讯分类' }
|
const TITLES: Record<number, string> = { 5: '行业管理', 3: '资讯分类' }
|
||||||
|
const pageTitle = computed(() => props.title || TITLES[props.type ?? 1] || '分类管理')
|
||||||
|
const catType = computed(() => props.type ?? 1)
|
||||||
|
|
||||||
const list = ref<Category[]>([])
|
const LIST = ref<Category[]>([])
|
||||||
const total = ref(0)
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const query = reactive({ page: 1, limit: 100 })
|
const dataSource = ref<'api' | 'mock' | 'loading'>('loading')
|
||||||
|
const apiError = ref<string>('')
|
||||||
|
const search = reactive({ name: '' })
|
||||||
|
|
||||||
const columns = [
|
// 状态胶囊 (全局 --st-* 变量: 0=正常绿 / 1=禁用红)
|
||||||
{ prop: 'id', label: 'ID', width: 80 },
|
const STATUS_META: Record<number, { label: string; color: string; bg: string }> = {
|
||||||
{ prop: 'name', label: '名称', minWidth: 200 },
|
0: { label: '正常', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
|
||||||
{ prop: 'level', label: '层级', width: 80, formatter: (r: Category) => ({ 1: '一级', 2: '二级', 3: '三级', 4: '四级' } as Record<number, string>)[r.level] || String(r.level) },
|
1: { label: '禁用', color: 'var(--st-err-fg)', bg: 'var(--st-err-bg)' }
|
||||||
{ prop: 'sort', label: '排序', width: 80 },
|
}
|
||||||
{ prop: 'status', label: '状态', width: 90, slot: 'status' },
|
function fmtDate(d: string | null | undefined): string {
|
||||||
{ prop: 'createDate', label: '创建时间', width: 180 }
|
if (!d) return '--'
|
||||||
]
|
return String(d).slice(0, 19).replace('T', ' ')
|
||||||
|
}
|
||||||
|
function statMeta(s: number) { return STATUS_META[s] ?? STATUS_META[0] }
|
||||||
|
|
||||||
|
// 树形过滤 (按名称关键字, 保留命中节点及其父链)
|
||||||
|
function filterTree(nodes: Category[], kw: string): Category[] {
|
||||||
|
if (!kw) return nodes
|
||||||
|
const out: Category[] = []
|
||||||
|
for (const n of nodes) {
|
||||||
|
const matched = (n.name ?? '').includes(kw)
|
||||||
|
const children = filterTree(n.children || [], kw)
|
||||||
|
if (matched || children.length) out.push({ ...n, children })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
const displayList = computed(() => filterTree(LIST.value, search.name.trim()))
|
||||||
|
|
||||||
|
// 找某节点在父级数组中的索引与同级数量, 用于排序上移/下移
|
||||||
|
function siblingInfo(node: Category): { idx: number; total: number } | null {
|
||||||
|
const list = node._parentArray
|
||||||
|
if (!list) return null
|
||||||
|
const idx = list.indexOf(node)
|
||||||
|
if (idx < 0) return null
|
||||||
|
return { idx, total: list.length }
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true; dataSource.value = 'loading'
|
||||||
try {
|
try {
|
||||||
const r: any = await httpGet('/other/category/categoryDeepPageList', { type: props.type ?? 1, page: query.page, limit: query.limit })
|
// categoryDeepPageList 含 status + createDate 字段 (新分类表接口)
|
||||||
list.value = r?.list || []
|
const data: any = await httpGet('/other/category/categoryDeepPageList', { type: catType.value, page: 1, limit: 9999 })
|
||||||
total.value = Number(r?.total || 0)
|
const flat: any[] = data?.list || data?.data?.list || []
|
||||||
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
|
LIST.value = buildTree(flat)
|
||||||
|
dataSource.value = 'api'; apiError.value = ''
|
||||||
|
} catch (e: any) {
|
||||||
|
dataSource.value = 'mock'
|
||||||
|
apiError.value = e?.msg || e?.message || '后端 API 暂未实装, 已加载前端示例数据'
|
||||||
|
LIST.value = []
|
||||||
|
} finally { loading.value = false }
|
||||||
}
|
}
|
||||||
function onPageChange(p: number) { query.page = p; load() }
|
|
||||||
function onLimitChange(l: number) { query.limit = l; query.page = 1; load() }
|
// 按 pid 组装树, 并在每个节点挂上 _parentArray 指针供上下移判断使用
|
||||||
|
function buildTree(flat: any[]): Category[] {
|
||||||
|
const map = new Map<string, any>()
|
||||||
|
flat.forEach((n: any) => map.set(String(n.id), { ...n, children: [] }))
|
||||||
|
const roots: any[] = []
|
||||||
|
for (const n of flat) {
|
||||||
|
const node = map.get(String(n.id))
|
||||||
|
const pid = String(n.pid ?? '0')
|
||||||
|
const parent = pid === '0' ? null : map.get(pid)
|
||||||
|
if (parent) parent.children.push(node)
|
||||||
|
else roots.push(node)
|
||||||
|
}
|
||||||
|
// 给每个节点挂上 _parentArray 引用, 方便 siblingInfo 判断位置
|
||||||
|
roots.forEach(r => attachParentRef(r, roots))
|
||||||
|
function attachParentRef(node: any, parentArr: any[]) {
|
||||||
|
node._parentArray = parentArr
|
||||||
|
if (node.children?.length) node.children.forEach((c: any) => attachParentRef(c, node.children))
|
||||||
|
}
|
||||||
|
const sortRec = (ns: any[]) => {
|
||||||
|
ns.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
|
||||||
|
ns.forEach((n) => sortRec(n.children || []))
|
||||||
|
}
|
||||||
|
sortRec(roots)
|
||||||
|
return roots
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchFields = [
|
||||||
|
{ key: 'name', label: '名称', type: 'text' as const, placeholder: '按名称搜索' }
|
||||||
|
]
|
||||||
|
const filters = reactive({ name: '' })
|
||||||
|
function onSearch() { search.name = filters.name || '' }
|
||||||
|
function onReset() { filters.name = ''; search.name = '' }
|
||||||
|
|
||||||
|
// ============ 新增/编辑 ============
|
||||||
|
const editVisible = ref(false)
|
||||||
|
const editMode = ref<'add' | 'edit'>('add')
|
||||||
|
const editForm = reactive({ id: '', name: '', level: 1, pid: '0', sort: 0, status: 0 })
|
||||||
|
function onAdd(parent?: Category) {
|
||||||
|
editMode.value = 'add'
|
||||||
|
editForm.id = ''
|
||||||
|
editForm.name = ''
|
||||||
|
editForm.pid = parent?.id || '0'
|
||||||
|
editForm.level = Math.min((parent?.level ?? 0) + 1, 5)
|
||||||
|
editForm.sort = 0
|
||||||
|
editForm.status = 0
|
||||||
|
editVisible.value = true
|
||||||
|
}
|
||||||
|
function onEdit(row: Category) {
|
||||||
|
editMode.value = 'edit'
|
||||||
|
editForm.id = row.id
|
||||||
|
editForm.name = row.name
|
||||||
|
editForm.pid = row.pid
|
||||||
|
editForm.level = row.level
|
||||||
|
editForm.sort = row.sort
|
||||||
|
editForm.status = row.status
|
||||||
|
editVisible.value = true
|
||||||
|
}
|
||||||
|
async function onSubmitEdit() {
|
||||||
|
const name = editForm.name.trim()
|
||||||
|
if (!name) { ElMessage.warning('请填写名称'); return }
|
||||||
|
if (name.length > 30) { ElMessage.warning('名称不能超过 30 个字符'); return }
|
||||||
|
const payload = editMode.value === 'add'
|
||||||
|
? { type: catType.value, name, status: editForm.status, pid: editForm.pid, level: editForm.level }
|
||||||
|
: { id: editForm.id, type: catType.value, name, status: editForm.status, pid: editForm.pid, level: editForm.level }
|
||||||
|
try {
|
||||||
|
await httpPost(editMode.value === 'add' ? '/other/category/save' : '/other/category/update', payload)
|
||||||
|
ElMessage.success(editMode.value === 'add' ? '新增成功' : '修改成功')
|
||||||
|
editVisible.value = false
|
||||||
|
load()
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 启用/禁用 ============
|
||||||
|
async function toggleStatus(row: Category) {
|
||||||
|
const disabling = row.status === 0
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`${disabling ? '禁用' : '启用'}「${row.name}」?${disabling ? '禁用后该分类将不对外展示' : ''}`,
|
||||||
|
'操作确认', { type: 'warning' }
|
||||||
|
)
|
||||||
|
} catch { return }
|
||||||
|
try {
|
||||||
|
await httpPost(`/other/category/enabled?id=${row.id}`)
|
||||||
|
ElMessage.success(disabling ? '已禁用' : '已启用')
|
||||||
|
load()
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 排序 (上移 type=1 / 下移 type=2) ============
|
||||||
|
async function moveRow(row: Category, direction: 'up' | 'down') {
|
||||||
|
const info = siblingInfo(row)
|
||||||
|
if (!info) { ElMessage.warning('无法移动'); return }
|
||||||
|
if (direction === 'up' && info.idx === 0) { ElMessage.warning('已是最顶部'); return }
|
||||||
|
if (direction === 'down' && info.idx === info.total - 1) { ElMessage.warning('已是最底部'); return }
|
||||||
|
try {
|
||||||
|
await httpGet('/other/category/moveCategoryNew', { id: row.id, type: direction === 'up' ? 1 : 2 })
|
||||||
|
ElMessage.success(direction === 'up' ? '已上移' : '已下移')
|
||||||
|
load()
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '排序失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 删除 (递归) ============
|
||||||
|
async function onDelete(row: Category) {
|
||||||
|
const hasChild = row.children?.length
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确认删除「${row.name}」?${hasChild ? `该分类下有 ${row.children!.length} 个子分类, 将一并删除!` : '删除后不可恢复。'}`,
|
||||||
|
'删除确认', { type: 'warning' }
|
||||||
|
)
|
||||||
|
} catch { return }
|
||||||
|
try {
|
||||||
|
await httpGet('/other/category/delete', { id: row.id })
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
load()
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '删除失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ prop: 'name', label: '名称', minWidth: 200 },
|
||||||
|
{ prop: 'status', label: '状态', width: 100, slot: 'status', align: 'center' as const },
|
||||||
|
{ prop: 'createDate', label: '创建时间', width: 170, slot: 'createTime' },
|
||||||
|
{ label: '排序', width: 170, slot: 'sort', align: 'center' as const },
|
||||||
|
{ label: '操作', width: 280, slot: 'actions', fixed: 'right' as const, align: 'center' as const }
|
||||||
|
]
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="rr-page">
|
<div class="rr-page">
|
||||||
<PageHeader :title="props.title || TITLES[props.type ?? 1] || '分类管理'" :sub="`共 ${total} 条`" />
|
<PageHeader :title="pageTitle" :sub="`共 ${LIST.length} 条分类`" :data-source="dataSource" :api-error="apiError">
|
||||||
<div class="rr-card">
|
<el-button type="primary" @click="onAdd()">
|
||||||
<DataTable :columns="columns" :data="list" :borderless="true" :loading="loading" :total="total" :page="query.page" :limit="query.limit" :show-pagination="false" row-key="id" :tree-props="{ children: 'children', hasChildren: 'hasChildren' }" empty-text="暂无数据" />
|
<el-icon style="margin-right:4px"><Plus /></el-icon>新增一级分类
|
||||||
|
</el-button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="rr-list-card">
|
||||||
|
<div class="rr-toolbar">
|
||||||
|
<el-input v-model="filters.name" placeholder="按名称搜索" clearable style="width:280px" @keyup.enter="onSearch" />
|
||||||
|
<el-button type="primary" @click="onSearch">查询</el-button>
|
||||||
|
<el-button @click="onReset">重置</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:columns="columns"
|
||||||
|
:data="displayList"
|
||||||
|
:loading="loading"
|
||||||
|
row-key="id"
|
||||||
|
borderless
|
||||||
|
empty-text="暂无分类"
|
||||||
|
:show-pagination="false"
|
||||||
|
:default-expand-all="false"
|
||||||
|
>
|
||||||
|
<template #status="{ row }">
|
||||||
|
<span class="st-pill" :style="{ color: statMeta(row.status).color, background: statMeta(row.status).bg }">
|
||||||
|
<span class="st-dot"></span>{{ statMeta(row.status).label }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #createTime="{ row }">
|
||||||
|
{{ fmtDate(row.createDate) }}
|
||||||
|
</template>
|
||||||
|
<template #sort="{ row }">
|
||||||
|
<el-button type="text" size="small" :disabled="siblingInfo(row)?.idx === 0" @click="moveRow(row, 'up')">↑ 上移</el-button>
|
||||||
|
<el-button type="text" size="small" :disabled="(siblingInfo(row)?.idx ?? -1) === (siblingInfo(row)?.total ?? 0) - 1" @click="moveRow(row, 'down')">↓ 下移</el-button>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<TableActions
|
||||||
|
:row="row"
|
||||||
|
:actions="[
|
||||||
|
{ label: '添加子级', type: 'primary', when: (r: any) => (r.level || 1) < 5, onClick: onAdd },
|
||||||
|
{ label: '修改', type: 'primary', onClick: onEdit },
|
||||||
|
{ label: row.status === 0 ? '禁用' : '启用', type: row.status === 0 ? 'warning' : 'success', onClick: () => toggleStatus(row) },
|
||||||
|
{ label: '删除', type: 'danger', divided: true, onClick: () => onDelete(row) }
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 新增/修改 弹窗 -->
|
||||||
|
<el-dialog v-model="editVisible" :title="editMode === 'add' ? '新增分类' : '修改分类'" width="500px" :close-on-click-modal="false" destroy-on-close>
|
||||||
|
<el-form label-width="100px" label-position="right">
|
||||||
|
<el-form-item label="名称" required>
|
||||||
|
<el-input v-model="editForm.name" placeholder="请输入名称" maxlength="30" show-word-limit clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-radio-group v-model="editForm.status">
|
||||||
|
<el-radio :value="0">正常</el-radio>
|
||||||
|
<el-radio :value="1">禁用</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="editVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="onSubmitEdit">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.rr-page { max-width: 1400px; }
|
/* 顶部搜索工具栏 (label-less, 与新后台其它列表页一致) */
|
||||||
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
|
.rr-toolbar {
|
||||||
|
padding: 14px 16px 0;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -142,7 +142,7 @@ onMounted(load)
|
|||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-dialog v-model="editVisible" title="新增单位" width="400px">
|
<el-dialog v-model="editVisible" title="新增单位" width="500px">
|
||||||
<el-form label-width="80px">
|
<el-form label-width="80px">
|
||||||
<el-form-item label="名称" required><el-input v-model="editForm.name" placeholder="如: 吨" /></el-form-item>
|
<el-form-item label="名称" required><el-input v-model="editForm.name" placeholder="如: 吨" /></el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
|||||||
import { httpGet, httpPost } from '@/utils/request'
|
import { httpGet, httpPost } from '@/utils/request'
|
||||||
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||||
import SearchBar from '@/components/SearchBar/SearchBar.vue'
|
import SearchBar from '@/components/SearchBar/SearchBar.vue'
|
||||||
|
import TableActions from '@/components/TableActions/TableActions.vue'
|
||||||
|
|
||||||
interface Groupbuy {
|
interface Groupbuy {
|
||||||
id: string
|
id: string
|
||||||
@ -29,9 +30,9 @@ interface Groupbuy {
|
|||||||
raw: any
|
raw: any
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_MAP: Record<number, { label: string; type: string }> = {
|
const STATUS_MAP: Record<number, { label: string; color: string; bg: string }> = {
|
||||||
1: { label: '启用', type: 'success' },
|
1: { label: '启用', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
|
||||||
2: { label: '停用', type: 'danger' }
|
2: { label: '停用', color: 'var(--st-err-fg)', bg: 'var(--st-err-bg)' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const FALLBACK: Groupbuy[] = [
|
const FALLBACK: Groupbuy[] = [
|
||||||
@ -102,9 +103,7 @@ onMounted(load)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="rr-page">
|
<div class="rr-page">
|
||||||
<PageHeader title="拼团活动" :sub="`拼团/团购活动管理 (共 ${total} 个)`">
|
<PageHeader title="拼团活动" :sub="`拼团/团购活动管理 (共 ${total} 个)`" />
|
||||||
<el-button type="danger" :disabled="selection.length === 0" @click="onBatchDelete">批量删除 ({{ selection.length }})</el-button>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<SearchBar
|
<SearchBar
|
||||||
v-model:filters="filters"
|
v-model:filters="filters"
|
||||||
@ -116,6 +115,38 @@ onMounted(load)
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="rr-card">
|
<div class="rr-card">
|
||||||
|
<div class="batch-bar" :class="{ 'has-selection': selection.length > 0 }">
|
||||||
|
<span v-if="selection.length > 0" class="batch-count">已选 <strong>{{ selection.length }}</strong> 个活动</span>
|
||||||
|
<span v-else class="batch-hint">勾选活动后可批量操作</span>
|
||||||
|
<span class="batch-actions">
|
||||||
|
<el-button size="small" type="danger" plain :disabled="!selection.length" @click="onBatchDelete">批量删除</el-button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<el-table :data="list" v-loading="loading" style="width: 100%" @selection-change="onSelectionChange" row-key="id" empty-text="暂无拼团活动">
|
||||||
|
<el-table-column type="selection" width="48" align="center" />
|
||||||
|
<el-table-column prop="name" label="活动名称" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="enterpriseName" label="活动商家" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="groupBeginTime" label="开始时间" width="150" />
|
||||||
|
<el-table-column prop="groupEndTime" label="结束时间" width="150" />
|
||||||
|
<el-table-column label="状态" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="st-pill" :style="{ color: STATUS_MAP[row.status]?.color, background: STATUS_MAP[row.status]?.bg }"><span class="st-dot"></span>{{ STATUS_MAP[row.status]?.label }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="createDate" label="创建时间" width="165" />
|
||||||
|
<el-table-column label="操作" width="180" align="center" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<TableActions
|
||||||
|
:row="row"
|
||||||
|
:actions="[
|
||||||
|
{ label: '详情', type: 'primary', onClick: onView },
|
||||||
|
{ label: row.status === 1 ? '停用' : '启用', type: row.status === 1 ? 'warning' : 'success', onClick: onToggleStatus },
|
||||||
|
{ label: '删除', type: 'danger', divided: true, onClick: onDelete }
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
<div class="rr-pagination">
|
<div class="rr-pagination">
|
||||||
<el-pagination :current-page="page" :page-sizes="[10, 20, 50, 100]" :page-size="limit" :total="total" layout="total, sizes, prev, pager, next, jumper" @size-change="onLimitChange" @current-change="onPageChange" />
|
<el-pagination :current-page="page" :page-sizes="[10, 20, 50, 100]" :page-size="limit" :total="total" layout="total, sizes, prev, pager, next, jumper" @size-change="onLimitChange" @current-change="onPageChange" />
|
||||||
</div>
|
</div>
|
||||||
@ -129,7 +160,7 @@ onMounted(load)
|
|||||||
<el-descriptions-item label="结束时间">{{ viewData.groupEndTime }}</el-descriptions-item>
|
<el-descriptions-item label="结束时间">{{ viewData.groupEndTime }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="活动商品" :span="2">{{ viewData.type }}</el-descriptions-item>
|
<el-descriptions-item label="活动商品" :span="2">{{ viewData.type }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="状态">
|
<el-descriptions-item label="状态">
|
||||||
<el-tag :type="(STATUS_MAP[viewData.status]?.type as any)" size="small">{{ STATUS_MAP[viewData.status]?.label }}</el-tag>
|
<span class="st-pill" :style="{ color: STATUS_MAP[viewData.status]?.color, background: STATUS_MAP[viewData.status]?.bg }"><span class="st-dot"></span>{{ STATUS_MAP[viewData.status]?.label }}</span>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="创建时间">{{ viewData.createDate }}</el-descriptions-item>
|
<el-descriptions-item label="创建时间">{{ viewData.createDate }}</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
|||||||
@ -1,61 +1,268 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* F006 资讯列表 - P1 真 view
|
* F006 资讯列表 - 真 view + 发布功能 (2026-08-08 增强)
|
||||||
*
|
*
|
||||||
* 走 /other/information/page
|
* 列表: GET /other/information/page
|
||||||
* 真实接口 (2026-08-07 探测: 200, total=7)
|
* 新增: POST /other/information/save (title/categoryId/categoryName/content/images)
|
||||||
* 字段: id/title/categoryId/categoryName/content/createTime
|
* 修改: POST /other/information/update
|
||||||
|
* 删除: POST /other/information/delete (body {id})
|
||||||
|
* 分类: GET /other/category/list?type=3&level=1 (一级分类, 排除无 level 的脏数据)
|
||||||
|
* 图片: POST /sys/file/upload (FormData, 返 data.url) — 参考旧系统 information-add-or-update.vue
|
||||||
*/
|
*/
|
||||||
import { onMounted, ref, reactive } from 'vue'
|
import { onMounted, ref, reactive, computed } from 'vue'
|
||||||
import { httpGet } from '@/utils/request'
|
import { httpGet, httpPost, httpDelete, http } from '@/utils/request'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||||
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||||
|
import RichEditor from '@/components/RichEditor/RichEditor.vue'
|
||||||
|
|
||||||
interface Info {
|
interface Info {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
categoryId: string
|
categoryId: string
|
||||||
categoryName: string
|
categoryName: string
|
||||||
|
content: string
|
||||||
|
images: string
|
||||||
createTime: string
|
createTime: string
|
||||||
|
status?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = ref<Info[]>([])
|
const list = ref<Info[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const query = reactive({ page: 1, limit: 10 })
|
const query = reactive({ page: 1, limit: 10 })
|
||||||
|
const search = reactive({ title: '' })
|
||||||
|
|
||||||
|
const categories = ref<{ id: string; name: string }[]>([])
|
||||||
|
const categoryLoading = ref(false)
|
||||||
|
|
||||||
|
// === 弹窗状态 ===
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const richEditorRef = ref<any>(null)
|
||||||
|
const editMode = ref<'create' | 'update'>('create')
|
||||||
|
const saving = ref(false)
|
||||||
|
const uploadLoading = ref(false)
|
||||||
|
const editForm = reactive({
|
||||||
|
id: '' as string,
|
||||||
|
title: '',
|
||||||
|
categoryId: '' as string,
|
||||||
|
categoryName: '',
|
||||||
|
content: '',
|
||||||
|
images: '',
|
||||||
|
})
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ prop: 'id', label: 'ID', width: 90 },
|
{ prop: 'id', label: 'ID', width: 90 },
|
||||||
{ prop: 'title', label: '资讯标题', minWidth: 280 },
|
{ label: '封面', width: 160, slot: 'cover' },
|
||||||
{ prop: 'categoryName', label: '分类', width: 140, formatter: (r: Info) => r.categoryName || '--' },
|
{ prop: 'title', label: '资讯标题', minWidth: 260 },
|
||||||
{ prop: 'createTime', label: '发布时间', width: 180 }
|
{ prop: 'categoryName', label: '分类', width: 120, formatter: (r: Info) => r.categoryName || '--' },
|
||||||
|
{ prop: 'status', label: '状态', width: 90, formatter: (r: Info) => r.status === 0 ? '草稿' : '已发布' },
|
||||||
|
{ prop: 'createTime', label: '发布时间', width: 170 },
|
||||||
|
{ label: '操作', width: 160, fixed: 'right' as const, slot: 'action' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const imgStyle = computed(() => ({
|
||||||
|
// 固定宽高 + cover 裁切: 封面列所有图统一尺寸 (原 max 约束按原图比例缩放导致大小不一)
|
||||||
|
width: '140px',
|
||||||
|
height: '48px',
|
||||||
|
objectFit: 'cover' as const,
|
||||||
|
borderRadius: '4px',
|
||||||
|
display: 'block' as const,
|
||||||
|
}))
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const resp: any = await httpGet('/other/information/page', { page: query.page, limit: query.limit })
|
const params: any = { page: query.page, limit: query.limit, status: 'all' }
|
||||||
|
if (search.title.trim()) params.title = search.title.trim()
|
||||||
|
const resp: any = await httpGet('/other/information/page', params)
|
||||||
list.value = resp?.list || []
|
list.value = resp?.list || []
|
||||||
total.value = Number(resp?.total || 0)
|
total.value = Number(resp?.total || 0)
|
||||||
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
|
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
|
||||||
}
|
}
|
||||||
function onPageChange(p: number) { query.page = p; load() }
|
function onPageChange(p: number) { query.page = p; load() }
|
||||||
function onLimitChange(l: number) { query.limit = l; query.page = 1; load() }
|
function onLimitChange(l: number) { query.limit = l; query.page = 1; load() }
|
||||||
|
function onSearch() { query.page = 1; load() }
|
||||||
|
function onReset() { search.title = ''; query.page = 1; load() }
|
||||||
|
|
||||||
|
// === 分类 ===
|
||||||
|
async function loadCategories() {
|
||||||
|
categoryLoading.value = true
|
||||||
|
try {
|
||||||
|
const data: any = await httpGet('/other/category/list', { type: 3, level: 1 })
|
||||||
|
categories.value = (data || [])
|
||||||
|
.filter((c: any) => c.level === 1 && c.id)
|
||||||
|
.map((c: any) => ({ id: String(c.id), name: c.name }))
|
||||||
|
.sort((a: any, b: any) => (a.name > b.name ? 1 : -1))
|
||||||
|
} catch { categories.value = [] } finally { categoryLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 新增/编辑 ===
|
||||||
|
function openCreate() {
|
||||||
|
editMode.value = 'create'
|
||||||
|
editForm.id = ''; editForm.title = ''; editForm.categoryId = ''; editForm.categoryName = ''; editForm.content = ''; editForm.images = ''
|
||||||
|
dialogVisible.value = true
|
||||||
|
loadCategories()
|
||||||
|
}
|
||||||
|
function openEdit(row: Info) {
|
||||||
|
editMode.value = 'update'
|
||||||
|
editForm.id = String(row.id)
|
||||||
|
editForm.title = row.title || ''
|
||||||
|
editForm.categoryId = row.categoryId ? String(row.categoryId) : ''
|
||||||
|
editForm.categoryName = row.categoryName || ''
|
||||||
|
editForm.content = row.content || ''
|
||||||
|
editForm.images = row.images || ''
|
||||||
|
dialogVisible.value = true
|
||||||
|
loadCategories()
|
||||||
|
}
|
||||||
|
function onCategoryChange(id: string) {
|
||||||
|
const c = categories.value.find(x => x.id === id)
|
||||||
|
editForm.categoryName = c?.name || ''
|
||||||
|
}
|
||||||
|
async function submit(status: number) {
|
||||||
|
if (!editForm.title.trim()) { ElMessage.warning('请填写标题'); return }
|
||||||
|
if (!editForm.categoryId) { ElMessage.warning('请选择分类'); return }
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const payload: any = {
|
||||||
|
title: editForm.title.trim(),
|
||||||
|
categoryId: editForm.categoryId,
|
||||||
|
categoryName: editForm.categoryName,
|
||||||
|
content: richEditorRef.value?.getContent?.() ?? editForm.content,
|
||||||
|
images: editForm.images,
|
||||||
|
status,
|
||||||
|
}
|
||||||
|
if (editMode.value === 'create') {
|
||||||
|
await httpPost('/other/information/save', payload)
|
||||||
|
ElMessage.success('新增成功')
|
||||||
|
} else {
|
||||||
|
payload.id = editForm.id
|
||||||
|
await httpPost('/other/information/update', payload)
|
||||||
|
ElMessage.success('修改成功')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
load()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.msg || e?.message || '保存失败')
|
||||||
|
} finally { saving.value = false }
|
||||||
|
}
|
||||||
|
async function onDelete(row: Info) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定删除资讯「${row.title}」吗?`, '提示', { type: 'warning' })
|
||||||
|
// 后端 delete 接口是 @RequestBody Long[] ids (JSON 数组), httpDelete 只支持 query 参数故直接用 http 实例
|
||||||
|
await http.delete('/other/information/delete', { data: [row.id] })
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
load()
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e === 'cancel' || e === 'close') return
|
||||||
|
ElMessage.error(e?.msg || e?.message || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 图片上传 (FormData + Bearer 头由拦截器自动带) ===
|
||||||
|
async function customUpload(opt: any) {
|
||||||
|
const file = opt.file as File
|
||||||
|
if (file.size > 5 * 1024 * 1024) { ElMessage.error('图片大小不能超过 5MB,请压缩后重试'); return }
|
||||||
|
uploadLoading.value = true
|
||||||
|
try {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
const { http } = await import('@/utils/request')
|
||||||
|
const res: any = await http.post('/sys/file/upload', fd, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
})
|
||||||
|
const body = res?.data
|
||||||
|
if (body?.code === 0 && body?.data?.url) {
|
||||||
|
editForm.images = body.data.url
|
||||||
|
ElMessage.success('图片上传成功')
|
||||||
|
} else {
|
||||||
|
ElMessage.error('图片上传失败: ' + (body?.msg || '未知错误'))
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error('图片上传失败: ' + (e?.message || '网络错误'))
|
||||||
|
} finally { uploadLoading.value = false }
|
||||||
|
}
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="rr-page">
|
<div class="rr-page">
|
||||||
<PageHeader title="资讯列表" :sub="`平台资讯 (共 ${total} 条)`" />
|
<PageHeader title="资讯列表" :sub="`平台资讯 (共 ${total} 条)`">
|
||||||
|
<el-button type="primary" @click="openCreate()">新增资讯</el-button>
|
||||||
|
</PageHeader>
|
||||||
<div class="rr-card">
|
<div class="rr-card">
|
||||||
|
<div class="rr-filters-row" style="padding: 16px 16px 0;">
|
||||||
|
<el-form inline>
|
||||||
|
<el-form-item label="标题">
|
||||||
|
<el-input v-model="search.title" placeholder="搜索标题" clearable style="width: 220px" @keyup.enter="onSearch" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="onSearch">查询</el-button>
|
||||||
|
<el-button @click="onReset">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
<DataTable :columns="columns" :data="list"
|
<DataTable :columns="columns" :data="list"
|
||||||
:borderless="true" :loading="loading" :total="total" :page="query.page" :limit="query.limit" :show-pagination="true" row-key="id" empty-text="暂无资讯" @page-change="onPageChange"
|
:borderless="true" :loading="loading" :total="total" :page="query.page" :limit="query.limit" :show-pagination="true" row-key="id" empty-text="暂无资讯" @page-change="onPageChange"
|
||||||
@limit-change="onLimitChange" />
|
@limit-change="onLimitChange">
|
||||||
|
<template #cover="{ row }">
|
||||||
|
<img v-if="row.images" :src="row.images" :style="imgStyle" alt="封面" referrerpolicy="no-referrer" />
|
||||||
|
<span v-else class="rr-muted">无图</span>
|
||||||
|
</template>
|
||||||
|
<template #action="{ row }">
|
||||||
|
<span class="rr-actions">
|
||||||
|
<el-link type="primary" @click="openEdit(row)">编辑</el-link>
|
||||||
|
<span class="rr-actions__sep">|</span>
|
||||||
|
<el-link type="danger" @click="onDelete(row)">删除</el-link>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 新增/编辑弹窗 -->
|
||||||
|
<el-dialog v-model="dialogVisible" :title="editMode === 'create' ? '新增资讯' : '编辑资讯'" width="1200px" destroy-on-close>
|
||||||
|
<el-form label-width="90px" label-position="right">
|
||||||
|
<el-form-item label="标题" required>
|
||||||
|
<el-input v-model="editForm.title" placeholder="资讯标题" maxlength="50" show-word-limit />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="一级分类" required>
|
||||||
|
<el-select v-model="editForm.categoryId" placeholder="请选择一级分类" :loading="categoryLoading" filterable style="width: 100%" @change="onCategoryChange">
|
||||||
|
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="封面图片">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<el-upload
|
||||||
|
:show-file-list="false"
|
||||||
|
accept="image/jpeg,image/png,image/jpg"
|
||||||
|
:http-request="customUpload"
|
||||||
|
:disabled="uploadLoading">
|
||||||
|
<img v-if="editForm.images" :src="editForm.images" alt="封面" class="upload-preview" referrerpolicy="no-referrer" />
|
||||||
|
<el-button v-else :loading="uploadLoading">上传封面</el-button>
|
||||||
|
</el-upload>
|
||||||
|
<span class="rr-muted">支持 JPG/PNG, 不超过 5MB</span>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="资讯内容">
|
||||||
|
<RichEditor ref="richEditorRef" v-model="editForm.content" style="margin-bottom: 8px" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button :loading="saving" @click="submit(0)">暂存</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="submit(1)">发布</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.rr-page { max-width: 1400px; }
|
.rr-page { max-width: 1400px; }
|
||||||
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
|
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
|
||||||
|
.rr-filters-row { display: flex; }
|
||||||
|
.rr-muted { color: var(--text-muted, #909399); font-size: 12px; }
|
||||||
|
.rr-actions { display: inline-flex; align-items: center; gap: 4px; }
|
||||||
|
.rr-actions__sep { color: var(--border, #e4e7ed); }
|
||||||
|
.upload-preview { max-width: 160px; max-height: 80px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border, #e4e7ed); }
|
||||||
</style>
|
</style>
|
||||||
@ -1,170 +1,296 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* F006 标签管理 - W10 A1 标准化
|
* F006 标签管理 - 树形增删改完整版 (2026-08-22)
|
||||||
*
|
*
|
||||||
* W10 A1: 套 PageHeader/SearchBar 标准化模板
|
* 沿用 CategoryDeepListView 范式 + 老后台 other/label.vue 字段
|
||||||
|
* 后端 type=7 (商品标签), 共用 tb_category 表 + ApiCategoryController
|
||||||
*
|
*
|
||||||
* 沿用老 vue other/label.vue 173 行:
|
* 后端端点:
|
||||||
* - 搜索: 标签名
|
* GET /other/category/list?type=7 平铺, 前端按 pid 组装树
|
||||||
* - 表格列: ID (自增序号) / 标签名 / 创建时间 / 状态 (启用/禁用)
|
* POST /other/category/save 新增
|
||||||
* - 操作: 修改 / 启用禁用 / 删除
|
* POST /other/category/update 修改
|
||||||
* - 树形结构 (children)
|
* GET /other/category/delete?id= 递归删子级 (GET!)
|
||||||
* - 增改弹窗
|
* POST /other/category/enabled?id= 启用/禁用
|
||||||
*
|
*
|
||||||
* 字段: id/name/status/createDate
|
* 老后台 URL: /vue/statistics/index-town#/other/label (W10 之前的页)
|
||||||
*/
|
*/
|
||||||
import { ref, onMounted, reactive } from 'vue'
|
import { onMounted, ref, reactive, computed } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { httpGet } from '@/utils/request'
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
import { httpGet, httpPost } from '@/utils/request'
|
||||||
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||||
import SearchBar from '@/components/SearchBar/SearchBar.vue'
|
import SearchBar, { type SearchField } from '@/components/SearchBar/SearchBar.vue'
|
||||||
|
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||||
import TableActions from '@/components/TableActions/TableActions.vue'
|
import TableActions from '@/components/TableActions/TableActions.vue'
|
||||||
|
|
||||||
interface Label {
|
interface Label {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
level: number
|
||||||
|
pid: string
|
||||||
|
sort: number
|
||||||
status: number
|
status: number
|
||||||
createDate: string
|
createDate: string
|
||||||
children?: Label[]
|
children?: Label[]
|
||||||
raw: any
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_MAP: Record<number, { label: string; type: string }> = {
|
// 标签类型固定为 7 (商品标签), 与老后台一致
|
||||||
0: { label: '启用', type: 'success' },
|
const LABEL_TYPE = 7
|
||||||
1: { label: '禁用', type: 'danger' }
|
|
||||||
|
const STATUS_META: Record<number, { label: string; color: string; bg: string }> = {
|
||||||
|
0: { label: '启用', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
|
||||||
|
1: { label: '禁用', color: 'var(--st-err-fg)', bg: 'var(--st-err-bg)' }
|
||||||
}
|
}
|
||||||
|
function fmtDate(d: string | null | undefined): string {
|
||||||
|
if (!d) return '--'
|
||||||
|
return String(d).slice(0, 19).replace('T', ' ')
|
||||||
|
}
|
||||||
|
function statMeta(s: number) { return STATUS_META[s] ?? STATUS_META[0] }
|
||||||
|
|
||||||
const FALLBACK: Label[] = [
|
const LIST = ref<Label[]>([])
|
||||||
{ id: '1', name: '热销', status: 0, createDate: '2026-07-01 10:00:00', raw: {} },
|
|
||||||
{ id: '2', name: '新品', status: 0, createDate: '2026-07-01 10:01:00', raw: {} },
|
|
||||||
{ id: '3', name: '推荐', status: 0, createDate: '2026-07-01 10:02:00', raw: {} },
|
|
||||||
{ id: '4', name: '限时', status: 0, createDate: '2026-07-02 09:00:00', raw: {} },
|
|
||||||
{ id: '5', name: '满减', status: 0, createDate: '2026-07-02 09:01:00', raw: {} },
|
|
||||||
{ id: '6', name: '包邮', status: 0, createDate: '2026-07-03 14:00:00', raw: {} },
|
|
||||||
{ id: '7', name: '正品', status: 0, createDate: '2026-07-03 14:01:00', raw: {} },
|
|
||||||
{ id: '8', name: '进口', status: 1, createDate: '2026-07-04 11:00:00', raw: {} }
|
|
||||||
]
|
|
||||||
|
|
||||||
const list = ref<Label[]>([])
|
|
||||||
const total = ref(0)
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const dataSource = ref<'api' | 'mock' | 'loading'>('loading')
|
||||||
const page = ref(1)
|
const apiError = ref<string>('')
|
||||||
const limit = ref(20)
|
const search = reactive({ name: '' })
|
||||||
const filters = reactive({ name: '' })
|
const selection = ref<Label[]>([])
|
||||||
|
|
||||||
const detailVisible = ref(false)
|
// 树形过滤 (按名称关键字, 保留命中节点及其父链)
|
||||||
const detailData = ref<Label | null>(null)
|
function filterTree(nodes: Label[], kw: string): Label[] {
|
||||||
const editVisible = ref(false)
|
if (!kw) return nodes
|
||||||
const editMode = ref<'add' | 'edit'>('add')
|
const out: Label[] = []
|
||||||
const editForm = reactive<Label>({ id: '', name: '', status: 0, createDate: '', raw: {} })
|
for (const n of nodes) {
|
||||||
|
const matched = (n.name ?? '').includes(kw)
|
||||||
|
const children = filterTree(n.children || [], kw)
|
||||||
|
if (matched || children.length) out.push({ ...n, children })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
const displayList = computed(() => filterTree(LIST.value, (search.name || '').trim()))
|
||||||
|
|
||||||
|
// 顶部统计
|
||||||
|
const stats = computed(() => {
|
||||||
|
const total = LIST.value.length
|
||||||
|
const enabled = LIST.value.filter(n => n.status === 0).length
|
||||||
|
const disabled = LIST.value.filter(n => n.status === 1).length
|
||||||
|
let maxLen = 0
|
||||||
|
const walk = (ns: Label[]) => {
|
||||||
|
for (const n of ns) {
|
||||||
|
if ((n.name || '').length > maxLen) maxLen = n.name.length
|
||||||
|
if (n.children?.length) walk(n.children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(LIST.value)
|
||||||
|
return [
|
||||||
|
{ label: '标签总数', value: total, icon: 'CollectionTag', color: 'var(--brand)' },
|
||||||
|
{ label: '已启用', value: enabled, icon: 'CircleCheck', color: 'var(--green)' },
|
||||||
|
{ label: '已禁用', value: disabled, icon: 'CircleClose', color: 'var(--red)' },
|
||||||
|
{ label: '最长名称', value: maxLen ? `${maxLen}字` : '--', icon: 'Histogram', color: 'var(--amber)' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true; dataSource.value = 'loading'
|
||||||
error.value = null
|
|
||||||
try {
|
try {
|
||||||
// /other/label/page 后端 404, 用 FALLBACK
|
const r: any = await httpGet('/other/category/list', { type: LABEL_TYPE })
|
||||||
await new Promise(r => setTimeout(r, 200))
|
const flat: any[] = r?.list || (Array.isArray(r) ? r : [])
|
||||||
let rows = [...FALLBACK]
|
LIST.value = buildTree(flat)
|
||||||
if (filters.name) rows = rows.filter(r => r.name.includes(filters.name))
|
dataSource.value = 'api'; apiError.value = ''
|
||||||
list.value = rows.map((r, i) => ({ ...r, _index: i + 1 }))
|
|
||||||
total.value = rows.length
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e?.msg || e?.message || '加载失败'
|
dataSource.value = 'mock'
|
||||||
list.value = FALLBACK.map((r, i) => ({ ...r, _index: i + 1 }))
|
apiError.value = e?.msg || e?.message || '后端 API 暂未实装, 已加载前端示例数据'
|
||||||
total.value = FALLBACK.length
|
LIST.value = []
|
||||||
} finally { loading.value = false }
|
} finally { loading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSearch() { page.value = 1; load() }
|
// 按 pid 组装树 (孤立节点挂根), 按 sort 升序
|
||||||
function onReset() { filters.name = ''; load() }
|
function buildTree(flat: any[]): Label[] {
|
||||||
function onPageChange(p: number) { page.value = p; load() }
|
const map = new Map<string, any>()
|
||||||
function onLimitChange(l: number) { limit.value = l; load() }
|
flat.forEach((n: any) => map.set(String(n.id), { ...n, children: [] }))
|
||||||
function onAdd() { editMode.value = 'add'; editForm.id = ''; editForm.name = ''; editForm.status = 0; editForm.createDate = ''; editVisible.value = true }
|
const roots: any[] = []
|
||||||
function onEdit(row: Label) { editMode.value = 'edit'; Object.assign(editForm, JSON.parse(JSON.stringify(row))); editVisible.value = true }
|
for (const n of flat) {
|
||||||
function onSubmitEdit() {
|
const node = map.get(String(n.id))
|
||||||
if (!editForm.name.trim()) { ElMessage.warning('请填写标签名'); return }
|
const pid = String(n.pid ?? '0')
|
||||||
ElMessage.success(editMode.value === 'add' ? '新增成功 (mock)' : '修改成功 (mock)')
|
const parent = pid === '0' ? null : map.get(pid)
|
||||||
editVisible.value = false
|
if (parent) parent.children.push(node)
|
||||||
|
else roots.push(node)
|
||||||
|
}
|
||||||
|
const sortRec = (ns: any[]) => {
|
||||||
|
ns.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
|
||||||
|
ns.forEach((n) => sortRec(n.children || []))
|
||||||
|
}
|
||||||
|
sortRec(roots)
|
||||||
|
return roots
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchFields: SearchField[] = [
|
||||||
|
{ key: 'name', label: '标签名称', type: 'text', placeholder: '请输入标签名' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ prop: 'name', label: '标签名称', minWidth: 200 },
|
||||||
|
{ prop: 'createDate', label: '创建时间', minWidth: 170, slot: 'createTime' },
|
||||||
|
{ prop: 'status', label: '状态', width: 110, slot: 'status', align: 'center' as const },
|
||||||
|
{ label: '操作', width: 200, slot: 'actions', fixed: 'right' as const, align: 'center' as const }
|
||||||
|
]
|
||||||
|
|
||||||
|
function onSelectionChange(rows: Label[]) { selection.value = rows }
|
||||||
|
function onSearchClick() { /* 搜索通过 reactive 触发 computed filter, 无需重 load */ }
|
||||||
|
function onReset() { search.name = '' }
|
||||||
|
|
||||||
|
// ============ 增改弹窗 ============
|
||||||
|
const editVisible = ref(false)
|
||||||
|
const editMode = ref<'add' | 'edit'>('add')
|
||||||
|
const editForm = reactive<{ id: string; name: string; status: number }>({ id: '', name: '', status: 0 })
|
||||||
|
|
||||||
|
function onAdd() { editMode.value = 'add'; editForm.id = ''; editForm.name = ''; editForm.status = 0; editVisible.value = true }
|
||||||
|
function onEdit(row: Label) {
|
||||||
|
editMode.value = 'edit'
|
||||||
|
editForm.id = String(row.id)
|
||||||
|
editForm.name = row.name ?? ''
|
||||||
|
editForm.status = row.status ?? 0
|
||||||
|
editVisible.value = true
|
||||||
|
}
|
||||||
|
async function onSubmitEdit() {
|
||||||
|
const name = editForm.name.trim()
|
||||||
|
if (!name) { ElMessage.warning('请填写标签名称'); return }
|
||||||
|
if (name.length > 30) { ElMessage.warning('标签名称不能超过 30 个字符'); return }
|
||||||
|
const payload = editMode.value === 'add'
|
||||||
|
? { type: LABEL_TYPE, name, status: editForm.status, pid: '0', level: 1 }
|
||||||
|
: { id: editForm.id, type: LABEL_TYPE, name, status: editForm.status }
|
||||||
|
try {
|
||||||
|
await httpPost(editMode.value === 'add' ? '/other/category/save' : '/other/category/update', payload)
|
||||||
|
ElMessage.success(editMode.value === 'add' ? '新增成功' : '修改成功')
|
||||||
|
editVisible.value = false
|
||||||
|
load()
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 启/禁用 ============
|
||||||
|
async function toggleStatus(row: Label) {
|
||||||
|
const disabling = row.status === 0
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`${disabling ? '禁用' : '启用'}「${row.name}」?${disabling ? '禁用后该标签将不对外展示' : ''}`,
|
||||||
|
'操作确认', { type: 'warning' }
|
||||||
|
)
|
||||||
|
} catch { return }
|
||||||
|
try {
|
||||||
|
await httpPost(`/other/category/enabled?id=${row.id}`)
|
||||||
|
ElMessage.success(disabling ? '已停用' : '已启用')
|
||||||
|
load()
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 删除 (递归) ============
|
||||||
|
async function onDelete(row: Label) {
|
||||||
|
const hasChild = row.children?.length
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确认删除「${row.name}」?${hasChild ? `该标签下有 ${row.children!.length} 个子标签, 将一并删除!` : '删除后不可恢复。'}`,
|
||||||
|
'删除确认', { type: 'warning' }
|
||||||
|
)
|
||||||
|
} catch { return }
|
||||||
|
try {
|
||||||
|
await httpGet('/other/category/delete', { id: row.id })
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
selection.value = []
|
||||||
|
load()
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '删除失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onBatchDelete() {
|
||||||
|
if (!selection.value.length) return
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确认批量删除选中的 ${selection.value.length} 个标签?子标签也会被一并删除!`,
|
||||||
|
'批量删除', { type: 'warning' }
|
||||||
|
).catch(() => {})
|
||||||
|
// 后端 /delete 不支持批量, 循环删除
|
||||||
|
let ok = 0
|
||||||
|
for (const row of selection.value) {
|
||||||
|
try { await httpGet('/other/category/delete', { id: row.id }); ok++ } catch {}
|
||||||
|
}
|
||||||
|
ElMessage.success(`已批量删除 ${ok} 条`)
|
||||||
|
selection.value = []
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
function onToggleStatus(row: Label) {
|
|
||||||
const newStatus = row.status === 0 ? 1 : 0
|
|
||||||
ElMessageBox.confirm(`确认${newStatus === 0 ? '启用' : '禁用'}「${row.name}」?`, '提示', { type: 'warning' }).then(() => {
|
|
||||||
ElMessage.success(`${newStatus === 0 ? '启用' : '禁用'}成功 (mock)`)
|
|
||||||
load()
|
|
||||||
}).catch(() => {})
|
|
||||||
}
|
|
||||||
function onDelete(row: Label) {
|
|
||||||
ElMessageBox.confirm(`确认删除「${row.name}」?`, '提示', { type: 'warning' }).then(() => {
|
|
||||||
ElMessage.success('已删除 (mock)')
|
|
||||||
load()
|
|
||||||
}).catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="rr-page">
|
<div class="rr-page">
|
||||||
<PageHeader title="标签管理" :sub="`商品/活动/服务标签分类管理 (共 ${total} 个)`">
|
<PageHeader title="标签管理" sub="商品/活动/服务标签分类管理" :data-source="dataSource" :api-error="apiError">
|
||||||
<el-button type="primary" @click="onAdd">新增标签</el-button>
|
<el-button type="primary" @click="onAdd">
|
||||||
|
<el-icon style="margin-right:4px"><Plus /></el-icon>新增标签
|
||||||
|
</el-button>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
<!-- 顶部统计卡 -->
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div v-for="(s, i) in stats" :key="i" class="stat-card">
|
||||||
|
<div class="stat-icon" :style="{ color: s.color, background: 'var(--brand-soft)' }">
|
||||||
|
<el-icon><component :is="s.icon" /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<div class="stat-label">{{ s.label }}</div>
|
||||||
|
<div class="stat-value">{{ s.value }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SearchBar
|
<SearchBar
|
||||||
v-model:filters="filters"
|
v-model:filters="search"
|
||||||
:fields="[
|
:fields="searchFields"
|
||||||
{ key: 'name', label: '标签名称', type: 'text', placeholder: '请输入标签名' }
|
@search="onSearchClick"
|
||||||
]"
|
|
||||||
@search="onSearch"
|
|
||||||
@reset="onReset"
|
@reset="onReset"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="rr-card">
|
<div class="rr-list-card">
|
||||||
|
<!-- 批量操作栏 -->
|
||||||
|
<div class="batch-bar" :class="{ 'has-selection': selection.length > 0 }">
|
||||||
|
<span v-if="selection.length > 0" class="batch-count">已选 <strong>{{ selection.length }}</strong> 个标签</span>
|
||||||
|
<span v-else class="batch-hint">勾选后可批量删除</span>
|
||||||
|
<div class="batch-actions">
|
||||||
|
<el-button size="small" type="danger" plain :disabled="!selection.length" @click="onBatchDelete">批量删除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
:columns="[
|
:columns="columns"
|
||||||
{ prop: 'name', label: '标签名称', minWidth: 200 },
|
:data="displayList"
|
||||||
{ prop: 'createDate', label: '创建时间', width: 180 },
|
|
||||||
{ prop: 'status', label: '状态', width: 100, slot: 'status' },
|
|
||||||
{ label: '操作', width: 140, align: 'center', slot: 'action' }
|
|
||||||
]"
|
|
||||||
:data="list"
|
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:total="total"
|
|
||||||
:page="page"
|
|
||||||
:limit="limit"
|
|
||||||
:show-pagination="total > 10" :page-sizes="[10, 20, 50, 100]"
|
|
||||||
:error="error"
|
|
||||||
empty-text="暂无标签"
|
|
||||||
borderless
|
|
||||||
row-key="id"
|
row-key="id"
|
||||||
@page-change="onPageChange"
|
borderless
|
||||||
@limit-change="onLimitChange"
|
empty-text="暂无标签"
|
||||||
|
:show-pagination="false"
|
||||||
|
:default-expand-all="false"
|
||||||
>
|
>
|
||||||
<template #status="{ row }">
|
<template #status="{ row }">
|
||||||
<el-tag :type="(STATUS_MAP[row.status]?.type as any)" size="small" effect="light">
|
<span class="st-pill" :style="{ color: statMeta(row.status).color, background: statMeta(row.status).bg }">
|
||||||
{{ STATUS_MAP[row.status]?.label }}
|
<span class="st-dot"></span>{{ statMeta(row.status).label }}
|
||||||
</el-tag>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template #action="{ row }">
|
<template #createTime="{ row }">
|
||||||
|
{{ fmtDate(row.createDate) }}
|
||||||
|
</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
<TableActions
|
<TableActions
|
||||||
:row="row"
|
:row="row"
|
||||||
:actions="[
|
:actions="[
|
||||||
{ label: '修改', type: 'primary', onClick: onEdit },
|
{ label: '修改', type: 'primary', onClick: onEdit },
|
||||||
{ label: (r: any) => r.status === 0 ? '禁用' : '启用', type: (r: any) => r.status === 0 ? 'warning' : 'success', onClick: onToggleStatus },
|
{ label: row.status === 0 ? '禁用' : '启用', type: row.status === 0 ? 'warning' : 'success', onClick: toggleStatus },
|
||||||
{ label: '删除', type: 'danger', onClick: onDelete }
|
{ label: '删除', type: 'danger', divided: true, onClick: onDelete }
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 增改弹窗 -->
|
<!-- 新增/修改 弹窗 -->
|
||||||
<el-dialog v-model="editVisible" :title="editMode === 'add' ? '新增标签' : '修改标签'" width="500px">
|
<el-dialog v-model="editVisible" :title="editMode === 'add' ? '新增标签' : '修改标签'" width="500px" :close-on-click-modal="false" destroy-on-close>
|
||||||
<el-form label-width="100px">
|
<el-form label-width="100px" label-position="right">
|
||||||
<el-form-item label="标签名称" required>
|
<el-form-item label="标签名称" required>
|
||||||
<el-input v-model="editForm.name" placeholder="请输入标签名" maxlength="20" show-word-limit />
|
<el-input v-model="editForm.name" placeholder="请输入标签名称" maxlength="30" show-word-limit clearable />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="状态">
|
<el-form-item label="状态">
|
||||||
<el-radio-group v-model="editForm.status">
|
<el-radio-group v-model="editForm.status">
|
||||||
@ -182,5 +308,24 @@ onMounted(load)
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.rr-page { max-width: 1400px; }
|
/* 顶部统计卡 */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg); padding: 16px 18px;
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
transition: all .15s;
|
||||||
|
&:hover { border-color: var(--border-strong); box-shadow: var(--shadow); }
|
||||||
|
}
|
||||||
|
.stat-icon {
|
||||||
|
width: 40px; height: 40px; border-radius: 10px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.stat-info { flex: 1; min-width: 0; }
|
||||||
|
.stat-label { font-size: 12px; color: var(--ink-3); margin-bottom: 4px; }
|
||||||
|
.stat-value { font-size: 22px; font-weight: 700; color: var(--ink); letter-spacing: -0.02em; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -214,7 +214,7 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 新增/编辑弹窗 -->
|
<!-- 新增/编辑弹窗 -->
|
||||||
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑变量' : '新增变量'" width="520px" destroy-on-close>
|
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑变量' : '新增变量'" width="500px" destroy-on-close>
|
||||||
<el-form label-width="90px" label-position="left">
|
<el-form label-width="90px" label-position="left">
|
||||||
<el-form-item label="变量标识" required>
|
<el-form-item label="变量标识" required>
|
||||||
<el-input v-model="form.varKey" placeholder="如 copper_price (英文/下划线, 唯一)" :disabled="!!form.id" />
|
<el-input v-model="form.varKey" placeholder="如 copper_price (英文/下划线, 唯一)" :disabled="!!form.id" />
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
|||||||
import { httpGet, httpPost } from '@/utils/request'
|
import { httpGet, httpPost } from '@/utils/request'
|
||||||
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||||
import SearchBar from '@/components/SearchBar/SearchBar.vue'
|
import SearchBar from '@/components/SearchBar/SearchBar.vue'
|
||||||
|
import TableActions from '@/components/TableActions/TableActions.vue'
|
||||||
|
|
||||||
interface Seckill {
|
interface Seckill {
|
||||||
id: string
|
id: string
|
||||||
@ -29,9 +30,9 @@ interface Seckill {
|
|||||||
raw: any
|
raw: any
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_MAP: Record<number, { label: string; type: string }> = {
|
const STATUS_MAP: Record<number, { label: string; color: string; bg: string }> = {
|
||||||
0: { label: '启用', type: 'success' },
|
0: { label: '启用', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
|
||||||
1: { label: '停用', type: 'danger' }
|
1: { label: '停用', color: 'var(--st-err-fg)', bg: 'var(--st-err-bg)' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const FALLBACK: Seckill[] = [
|
const FALLBACK: Seckill[] = [
|
||||||
@ -103,9 +104,7 @@ onMounted(load)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="rr-page">
|
<div class="rr-page">
|
||||||
<PageHeader title="秒杀活动" :sub="`限时秒杀活动管理 (共 ${total} 个)`">
|
<PageHeader title="秒杀活动" :sub="`限时秒杀活动管理 (共 ${total} 个)`" />
|
||||||
<el-button type="danger" :disabled="selection.length === 0" @click="onBatchDelete">批量删除 ({{ selection.length }})</el-button>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<SearchBar
|
<SearchBar
|
||||||
v-model:filters="filters"
|
v-model:filters="filters"
|
||||||
@ -117,6 +116,38 @@ onMounted(load)
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="rr-card">
|
<div class="rr-card">
|
||||||
|
<div class="batch-bar" :class="{ 'has-selection': selection.length > 0 }">
|
||||||
|
<span v-if="selection.length > 0" class="batch-count">已选 <strong>{{ selection.length }}</strong> 个活动</span>
|
||||||
|
<span v-else class="batch-hint">勾选活动后可批量操作</span>
|
||||||
|
<span class="batch-actions">
|
||||||
|
<el-button size="small" type="danger" plain :disabled="!selection.length" @click="onBatchDelete">批量删除</el-button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<el-table :data="list" v-loading="loading" style="width: 100%" @selection-change="onSelectionChange" row-key="id" empty-text="暂无秒杀活动">
|
||||||
|
<el-table-column type="selection" width="48" align="center" />
|
||||||
|
<el-table-column prop="name" label="活动名称" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="enterpriseName" label="活动商家" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="beginTime" label="开始时间" width="150" />
|
||||||
|
<el-table-column prop="endTime" label="结束时间" width="150" />
|
||||||
|
<el-table-column label="状态" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="st-pill" :style="{ color: STATUS_MAP[row.status]?.color, background: STATUS_MAP[row.status]?.bg }"><span class="st-dot"></span>{{ STATUS_MAP[row.status]?.label }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="createDate" label="创建时间" width="165" />
|
||||||
|
<el-table-column label="操作" width="180" align="center" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<TableActions
|
||||||
|
:row="row"
|
||||||
|
:actions="[
|
||||||
|
{ label: '详情', type: 'primary', onClick: onView },
|
||||||
|
{ label: row.status === 0 ? '停用' : '启用', type: row.status === 0 ? 'warning' : 'success', onClick: onToggleStatus },
|
||||||
|
{ label: '删除', type: 'danger', divided: true, onClick: onDelete }
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
<div class="rr-pagination">
|
<div class="rr-pagination">
|
||||||
<el-pagination :current-page="page" :page-sizes="[10, 20, 50, 100]" :page-size="limit" :total="total" layout="total, sizes, prev, pager, next, jumper" @size-change="onLimitChange" @current-change="onPageChange" />
|
<el-pagination :current-page="page" :page-sizes="[10, 20, 50, 100]" :page-size="limit" :total="total" layout="total, sizes, prev, pager, next, jumper" @size-change="onLimitChange" @current-change="onPageChange" />
|
||||||
</div>
|
</div>
|
||||||
@ -130,7 +161,7 @@ onMounted(load)
|
|||||||
<el-descriptions-item label="结束时间">{{ viewData.endTime }}</el-descriptions-item>
|
<el-descriptions-item label="结束时间">{{ viewData.endTime }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="活动商品" :span="2">{{ viewData.type }}</el-descriptions-item>
|
<el-descriptions-item label="活动商品" :span="2">{{ viewData.type }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="状态">
|
<el-descriptions-item label="状态">
|
||||||
<el-tag :type="(STATUS_MAP[viewData.status]?.type as any)" size="small">{{ STATUS_MAP[viewData.status]?.label }}</el-tag>
|
<span class="st-pill" :style="{ color: STATUS_MAP[viewData.status]?.color, background: STATUS_MAP[viewData.status]?.bg }"><span class="st-dot"></span>{{ STATUS_MAP[viewData.status]?.label }}</span>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="创建时间">{{ viewData.createDate }}</el-descriptions-item>
|
<el-descriptions-item label="创建时间">{{ viewData.createDate }}</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
|||||||
import { httpGet, httpPost } from '@/utils/request'
|
import { httpGet, httpPost } from '@/utils/request'
|
||||||
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||||
import SearchBar from '@/components/SearchBar/SearchBar.vue'
|
import SearchBar from '@/components/SearchBar/SearchBar.vue'
|
||||||
|
import TableActions from '@/components/TableActions/TableActions.vue'
|
||||||
|
|
||||||
interface Brand {
|
interface Brand {
|
||||||
id: string
|
id: string
|
||||||
@ -33,10 +34,10 @@ interface Brand {
|
|||||||
raw: any
|
raw: any
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_MAP: Record<number, { label: string; type: string }> = {
|
const STATUS_MAP: Record<number, { label: string; color: string; bg: string }> = {
|
||||||
1: { label: '待审核', type: 'warning' },
|
1: { label: '待审核', color: 'var(--st-warn-fg)', bg: 'var(--st-warn-bg)' },
|
||||||
2: { label: '审核通过', type: 'success' },
|
2: { label: '审核通过', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
|
||||||
3: { label: '审核拒绝', type: 'danger' }
|
3: { label: '审核拒绝', color: 'var(--st-err-fg)', bg: 'var(--st-err-bg)' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = ref<Brand[]>([])
|
const list = ref<Brand[]>([])
|
||||||
@ -173,9 +174,7 @@ onMounted(load)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="rr-page">
|
<div class="rr-page">
|
||||||
<PageHeader title="品牌管理" :sub="`供应商品牌审核与授权管理 (共 ${total} 个)`">
|
<PageHeader title="品牌管理" :sub="`供应商品牌审核与授权管理 (共 ${total} 个)`" />
|
||||||
<el-button type="danger" :disabled="selection.length === 0" @click="onBatchDelete">批量删除 ({{ selection.length }})</el-button>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<SearchBar
|
<SearchBar
|
||||||
v-model:filters="filters"
|
v-model:filters="filters"
|
||||||
@ -206,25 +205,26 @@ onMounted(load)
|
|||||||
<el-table-column prop="brandName" label="品牌名称" header-align="center" align="center" min-width="120" show-overflow-tooltip />
|
<el-table-column prop="brandName" label="品牌名称" header-align="center" align="center" min-width="120" show-overflow-tooltip />
|
||||||
<el-table-column label="品牌授权书" header-align="center" align="center" width="90">
|
<el-table-column label="品牌授权书" header-align="center" align="center" width="90">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-image v-if="row.authUrl" :src="row.authUrl" fit="cover" :preview-src-list="[row.authUrl]" class="brand-auth" />
|
<el-image v-if="row.authUrl" :src="row.authUrl" fit="cover" :preview-src-list="[row.authUrl]" class="brand-auth" preview-teleported />
|
||||||
<span v-else class="rr-muted">--</span>
|
<span v-else class="rr-muted">--</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="status" label="审核状态" header-align="center" align="center" width="100">
|
<el-table-column prop="status" label="审核状态" header-align="center" align="center" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="STATUS_MAP[row.status]?.type || 'info'" size="small">{{ STATUS_MAP[row.status]?.label || row.status }}</el-tag>
|
<span class="st-pill" :style="{ color: STATUS_MAP[row.status]?.color || 'var(--st-lv4-fg)', background: STATUS_MAP[row.status]?.bg || 'var(--st-lv4-bg)' }"><span class="st-dot"></span>{{ STATUS_MAP[row.status]?.label || row.status }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="createDate" label="申请时间" header-align="center" align="center" width="170" show-overflow-tooltip />
|
<el-table-column prop="createDate" label="申请时间" header-align="center" align="center" width="170" show-overflow-tooltip />
|
||||||
<el-table-column label="操作" fixed="right" header-align="center" align="center" width="200">
|
<el-table-column label="操作" fixed="right" header-align="center" align="center" width="200">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="rr-actions">
|
<TableActions
|
||||||
<el-link v-if="row.sourceType === 1" type="primary" :underline="false" @click="onViewAuth(row)">查看授权</el-link>
|
:row="row"
|
||||||
<span v-if="row.sourceType === 1" class="rr-actions__sep">|</span>
|
:actions="[
|
||||||
<el-link v-if="row.status === 1" type="warning" :underline="false" @click="onAudit(row)">审核</el-link>
|
{ label: '查看授权', type: 'primary', when: (r: any) => r.sourceType === 1, onClick: onViewAuth },
|
||||||
<span v-if="row.status === 1" class="rr-actions__sep">|</span>
|
{ label: '审核', type: 'warning', when: (r: any) => r.status === 1, onClick: onAudit },
|
||||||
<el-link type="danger" :underline="false" @click="onDelete(row)">删除</el-link>
|
{ label: '删除', type: 'danger', divided: true, onClick: onDelete }
|
||||||
</span>
|
]"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@ -241,7 +241,7 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-dialog v-model="auditVisible" title="品牌审核" width="440px" :close-on-click-modal="false">
|
<el-dialog v-model="auditVisible" title="品牌审核" width="500px" :close-on-click-modal="false">
|
||||||
<el-form label-width="80px">
|
<el-form label-width="80px">
|
||||||
<el-form-item label="审核结果">
|
<el-form-item label="审核结果">
|
||||||
<el-radio-group v-model="auditForm.status">
|
<el-radio-group v-model="auditForm.status">
|
||||||
@ -265,7 +265,7 @@ onMounted(load)
|
|||||||
<el-table-column prop="sourceEnterpriseName" label="品牌来源" header-align="center" align="center" min-width="140" show-overflow-tooltip />
|
<el-table-column prop="sourceEnterpriseName" label="品牌来源" header-align="center" align="center" min-width="140" show-overflow-tooltip />
|
||||||
<el-table-column prop="status" label="审核状态" header-align="center" align="center" width="100">
|
<el-table-column prop="status" label="审核状态" header-align="center" align="center" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="STATUS_MAP[row.status]?.type || 'info'" size="small">{{ STATUS_MAP[row.status]?.label || row.status }}</el-tag>
|
<span class="st-pill" :style="{ color: STATUS_MAP[row.status]?.color || 'var(--st-lv4-fg)', background: STATUS_MAP[row.status]?.bg || 'var(--st-lv4-bg)' }"><span class="st-dot"></span>{{ STATUS_MAP[row.status]?.label || row.status }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="createDate" label="申请时间" header-align="center" align="center" width="170" />
|
<el-table-column prop="createDate" label="申请时间" header-align="center" align="center" width="170" />
|
||||||
@ -279,7 +279,7 @@ onMounted(load)
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.rr-page { max-width: 1500px; }
|
.rr-page { max-width: 1500px; }
|
||||||
.rr-card { background: var(--el-bg-color); border-radius: 8px; border: 1px solid var(--el-border-color-light); overflow: hidden; }
|
.rr-card { background: var(--el-bg-color); border-radius: 12px; border: 1px solid var(--el-border-color-light); overflow: hidden; }
|
||||||
.rr-pagination { padding: 12px 20px; display: flex; justify-content: flex-end; border-top: 1px solid var(--el-border-color-light); }
|
.rr-pagination { padding: 12px 20px; display: flex; justify-content: flex-end; border-top: 1px solid var(--el-border-color-light); }
|
||||||
.brand-auth { width: 50px; height: 50px; border-radius: 4px; border: 1px solid var(--el-border-color-light); cursor: pointer; }
|
.brand-auth { width: 50px; height: 50px; border-radius: 4px; border: 1px solid var(--el-border-color-light); cursor: pointer; }
|
||||||
.rr-muted { color: var(--el-text-color-placeholder); }
|
.rr-muted { color: var(--el-text-color-placeholder); }
|
||||||
|
|||||||
@ -149,7 +149,7 @@ onMounted(load)
|
|||||||
<template #content="{ row }">
|
<template #content="{ row }">
|
||||||
<span v-if="row.type === 1" class="rr-content-text">{{ row.content }}</span>
|
<span v-if="row.type === 1" class="rr-content-text">{{ row.content }}</span>
|
||||||
<el-button v-else-if="row.type === 2" text type="primary" size="small" @click="onShowRichText(row.content)">查看富文本</el-button>
|
<el-button v-else-if="row.type === 2" text type="primary" size="small" @click="onShowRichText(row.content)">查看富文本</el-button>
|
||||||
<el-image v-else-if="row.type === 3" :src="row.content" :preview-src-list="[row.content]" style="width: 60px; height: 60px; border-radius: 4px" fit="cover" />
|
<el-image v-else-if="row.type === 3" :src="row.content" :preview-src-list="[row.content]" style="width: 60px; height: 60px; border-radius: 4px" fit="cover" preview-teleported />
|
||||||
</template>
|
</template>
|
||||||
<template #action="{ row }">
|
<template #action="{ row }">
|
||||||
<el-button text type="primary" size="small" @click="onEdit(row)">修改</el-button>
|
<el-button text type="primary" size="small" @click="onEdit(row)">修改</el-button>
|
||||||
@ -164,7 +164,7 @@ onMounted(load)
|
|||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 增改弹窗 -->
|
<!-- 增改弹窗 -->
|
||||||
<el-dialog v-model="editVisible" :title="editMode === 'add' ? '新增常量' : '修改常量'" width="600px">
|
<el-dialog v-model="editVisible" :title="editMode === 'add' ? '新增常量' : '修改常量'" width="640px">
|
||||||
<el-form label-width="100px">
|
<el-form label-width="100px">
|
||||||
<el-form-item label="编码" required>
|
<el-form-item label="编码" required>
|
||||||
<el-input v-model="editForm.code" placeholder="如 system.title" :disabled="editMode === 'edit'" />
|
<el-input v-model="editForm.code" placeholder="如 system.title" :disabled="editMode === 'edit'" />
|
||||||
|
|||||||
@ -15,16 +15,14 @@
|
|||||||
*
|
*
|
||||||
* 失败回退: FALLBACK_*
|
* 失败回退: FALLBACK_*
|
||||||
*/
|
*/
|
||||||
import { ref, onMounted, watch, nextTick } from 'vue'
|
import { ref, onMounted, defineAsyncComponent } from 'vue'
|
||||||
import { httpGet } from '@/utils/request'
|
import { httpGet } from '@/utils/request'
|
||||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||||
import * as echarts from 'echarts/core'
|
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||||
import { LineChart, PieChart } from 'echarts/charts'
|
|
||||||
import { TooltipComponent, GridComponent, LegendComponent } from 'echarts/components'
|
|
||||||
import { CanvasRenderer } from 'echarts/renderers'
|
|
||||||
import { useThemeStore } from '@/stores/theme'
|
|
||||||
|
|
||||||
echarts.use([LineChart, PieChart, TooltipComponent, GridComponent, LegendComponent, CanvasRenderer])
|
// 图表组件: echarts 在组件内动态 import, 独立 chunk 懒加载 (2026-08-18)
|
||||||
|
const TrendChart = defineAsyncComponent(() => import('./charts/EchartsTrendChart.vue'))
|
||||||
|
const StatusPie = defineAsyncComponent(() => import('./charts/EchartsStatusPie.vue'))
|
||||||
|
|
||||||
interface OrderItem {
|
interface OrderItem {
|
||||||
orderId: string
|
orderId: string
|
||||||
@ -51,12 +49,6 @@ const recentOrders = ref<any[]>([])
|
|||||||
const trendData = ref<{ dates: string[]; counts: number[]; amounts: number[] }>({ dates: [], counts: [], amounts: [] })
|
const trendData = ref<{ dates: string[]; counts: number[]; amounts: number[] }>({ dates: [], counts: [], amounts: [] })
|
||||||
const statusSlices = ref<{ name: string; value: number; color: string }[]>([])
|
const statusSlices = ref<{ name: string; value: number; color: string }[]>([])
|
||||||
const apiStatus = ref<'loading' | 'live' | 'fallback'>('loading')
|
const apiStatus = ref<'loading' | 'live' | 'fallback'>('loading')
|
||||||
const trendChartEl = ref<HTMLDivElement | null>(null)
|
|
||||||
const statusChartEl = ref<HTMLDivElement | null>(null)
|
|
||||||
let trendChart: echarts.ECharts | null = null
|
|
||||||
let statusChart: echarts.ECharts | null = null
|
|
||||||
const themeStore = useThemeStore()
|
|
||||||
|
|
||||||
// 订单状态映射 (老 renren: 1=待付款 2=已付款 3=已发货 4=已完成 5=已取消)
|
// 订单状态映射 (老 renren: 1=待付款 2=已付款 3=已发货 4=已完成 5=已取消)
|
||||||
const ORDER_STATUS: Record<number, { label: string; type: string }> = {
|
const ORDER_STATUS: Record<number, { label: string; type: string }> = {
|
||||||
1: { label: '待付款', type: 'warning' },
|
1: { label: '待付款', type: 'warning' },
|
||||||
@ -213,88 +205,15 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
apiStatus.value = isLive ? 'live' : 'fallback'
|
apiStatus.value = isLive ? 'live' : 'fallback'
|
||||||
|
|
||||||
await nextTick()
|
|
||||||
renderTrend()
|
|
||||||
renderStatus()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ECharts 渲染
|
|
||||||
function getBrand(): string { return getComputedStyle(document.body).getPropertyValue('--brand').trim() || '#2563EB' }
|
|
||||||
function getInk(): string { return getComputedStyle(document.body).getPropertyValue('--ink-3').trim() || '#64748B' }
|
|
||||||
function getBorder(): string { return getComputedStyle(document.body).getPropertyValue('--border').trim() || '#E2E8F0' }
|
|
||||||
function getSurface(): string { return getComputedStyle(document.body).getPropertyValue('--surface').trim() || '#FFFFFF' }
|
|
||||||
function getChartColors(): string[] {
|
|
||||||
return ['--chart-1', '--chart-2', '--chart-3', '--chart-4', '--red'].map(v =>
|
|
||||||
getComputedStyle(document.body).getPropertyValue(v).trim()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTrend() {
|
|
||||||
if (!trendChartEl.value) return
|
|
||||||
if (!trendChart) trendChart = echarts.init(trendChartEl.value)
|
|
||||||
const ink = getInk(), border = getBorder(), surface = getSurface(), brand = getBrand()
|
|
||||||
const chart2 = getComputedStyle(document.body).getPropertyValue('--chart-2').trim() || '#7C3AED'
|
|
||||||
trendChart.setOption({
|
|
||||||
grid: { left: 50, right: 50, top: 40, bottom: 30 },
|
|
||||||
tooltip: { trigger: 'axis', backgroundColor: surface, borderColor: border, textStyle: { color: ink } },
|
|
||||||
legend: { top: 0, textStyle: { color: ink } },
|
|
||||||
xAxis: { type: 'category', data: trendData.value.dates, axisLine: { lineStyle: { color: border } }, axisLabel: { color: ink } },
|
|
||||||
yAxis: [
|
|
||||||
{ type: 'value', name: '订单数', position: 'left', splitLine: { lineStyle: { color: border } }, axisLabel: { color: ink } },
|
|
||||||
{ type: 'value', name: '交易额', position: 'right', splitLine: { show: false }, axisLabel: { color: ink, formatter: (v: number) => v >= 1000 ? `${(v/1000).toFixed(0)}K` : String(v) } }
|
|
||||||
],
|
|
||||||
series: [
|
|
||||||
{ name: '订单数', type: 'line', data: trendData.value.counts, smooth: true, itemStyle: { color: brand }, lineStyle: { color: brand, width: 2 }, areaStyle: { color: brand, opacity: 0.15 } },
|
|
||||||
{ name: '交易额', type: 'line', yAxisIndex: 1, data: trendData.value.amounts, smooth: true, itemStyle: { color: chart2 }, lineStyle: { color: chart2, width: 2 } }
|
|
||||||
]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderStatus() {
|
|
||||||
if (!statusChartEl.value) return
|
|
||||||
if (!statusChart) statusChart = echarts.init(statusChartEl.value)
|
|
||||||
const ink = getInk(), border = getBorder(), surface = getSurface()
|
|
||||||
const colors = getChartColors()
|
|
||||||
const total = statusSlices.value.reduce((a, b) => a + b.value, 0)
|
|
||||||
statusChart.setOption({
|
|
||||||
tooltip: { trigger: 'item', backgroundColor: surface, borderColor: border, textStyle: { color: ink } },
|
|
||||||
legend: { bottom: 0, textStyle: { color: ink } },
|
|
||||||
series: [{
|
|
||||||
type: 'pie', radius: ['58%', '80%'], center: ['50%', '45%'],
|
|
||||||
avoidLabelOverlap: true,
|
|
||||||
label: { show: false }, labelLine: { show: false },
|
|
||||||
data: statusSlices.value.map((s, i) => ({ name: s.name, value: s.value, itemStyle: { color: colors[i] } })),
|
|
||||||
emphasis: { label: { show: true, fontSize: 14, fontWeight: 600, color: ink } }
|
|
||||||
}],
|
|
||||||
graphic: total > 0 ? [
|
|
||||||
{ type: 'text', left: 'center', top: '38%', style: { text: '总计', fontSize: 10, fill: ink, opacity: 0.5 } },
|
|
||||||
{ type: 'text', left: 'center', top: '45%', style: { text: String(total), fontSize: 24, fontWeight: 700, fill: ink } }
|
|
||||||
] : []
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
watch([() => trendData.value, () => statusSlices.value, () => themeStore.current], async () => {
|
|
||||||
await nextTick()
|
|
||||||
renderTrend()
|
|
||||||
renderStatus()
|
|
||||||
})
|
|
||||||
|
|
||||||
window.addEventListener('resize', () => { trendChart?.resize(); statusChart?.resize() })
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="rr-dashboard">
|
<div class="rr-dashboard">
|
||||||
<div class="rr-page-header">
|
<PageHeader title="数据统计" :sub="`2026 年 7 月 4 日 · 实时数据`">
|
||||||
<div>
|
<el-tag v-if="apiStatus !== 'loading'" size="small" :type="apiStatus === 'live' ? 'success' : 'info'" effect="light">{{ apiStatus === 'live' ? '实时' : '示例数据' }}</el-tag>
|
||||||
<h2 class="rr-page-title">数据统计</h2>
|
</PageHeader>
|
||||||
<p class="rr-page-sub">
|
|
||||||
2026 年 7 月 4 日 · 实时数据
|
|
||||||
<el-tag v-if="apiStatus !== 'loading'" size="small" :type="apiStatus === 'live' ? 'success' : 'info'" effect="light" class="rr-api-badge">
|
|
||||||
{{ apiStatus === 'live' ? '实时' : '示例数据' }}
|
|
||||||
</el-tag>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rr-stats-grid">
|
<div class="rr-stats-grid">
|
||||||
<div v-for="(s, i) in stats" :key="i" class="rr-stat">
|
<div v-for="(s, i) in stats" :key="i" class="rr-stat">
|
||||||
@ -315,13 +234,13 @@ window.addEventListener('resize', () => { trendChart?.resize(); statusChart?.res
|
|||||||
<div class="rr-card-header">
|
<div class="rr-card-header">
|
||||||
<h3 class="rr-card-title">订单趋势(近 7 天)</h3>
|
<h3 class="rr-card-title">订单趋势(近 7 天)</h3>
|
||||||
</div>
|
</div>
|
||||||
<div ref="trendChartEl" class="rr-chart"></div>
|
<TrendChart :dates="trendData.dates" :counts="trendData.counts" :amounts="trendData.amounts" />
|
||||||
</div>
|
</div>
|
||||||
<div class="rr-card rr-chart-card">
|
<div class="rr-card rr-chart-card">
|
||||||
<div class="rr-card-header">
|
<div class="rr-card-header">
|
||||||
<h3 class="rr-card-title">订单状态分布</h3>
|
<h3 class="rr-card-title">订单状态分布</h3>
|
||||||
</div>
|
</div>
|
||||||
<div ref="statusChartEl" class="rr-chart"></div>
|
<StatusPie :slices="statusSlices" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -336,11 +255,8 @@ window.addEventListener('resize', () => { trendChart?.resize(); statusChart?.res
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.rr-dashboard { max-width: 1400px; }
|
.rr-dashboard { width: 100%; }
|
||||||
.rr-page-header { margin-bottom: 20px; }
|
/* header 已抽到 PageHeader 组件, 此处不再维护 */
|
||||||
.rr-page-title { font-size: 22px; font-weight: 600; letter-spacing: -0.02em; color: var(--ink); margin-bottom: 4px; }
|
|
||||||
.rr-page-sub { color: var(--ink-3); font-size: 13px; }
|
|
||||||
.rr-api-badge { margin-left: 8px; }
|
|
||||||
.rr-stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
|
.rr-stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
|
||||||
.rr-stat { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 18px 20px; transition: all 0.15s; &:hover { border-color: var(--border-strong); box-shadow: var(--shadow); } }
|
.rr-stat { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 18px 20px; transition: all 0.15s; &:hover { border-color: var(--border-strong); box-shadow: var(--shadow); } }
|
||||||
.rr-stat-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
.rr-stat-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||||
|
|||||||
94
src/modules/statistics/charts/EchartsStatusPie.vue
Normal file
94
src/modules/statistics/charts/EchartsStatusPie.vue
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 统计页 - 订单状态分布环形图 (2026-08-18 从 StatisticsView 抽出)
|
||||||
|
* echarts 动态 import → 独立 chunk 懒加载
|
||||||
|
*/
|
||||||
|
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||||
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
|
||||||
|
defineOptions({ name: 'EchartsStatusPie' })
|
||||||
|
|
||||||
|
const props = defineProps<{ slices: { name: string; value: number; color?: string }[] }>()
|
||||||
|
|
||||||
|
const el = ref<HTMLDivElement | null>(null)
|
||||||
|
let chart: any = null
|
||||||
|
let echartsMod: any = null
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
|
||||||
|
const total = computed(() => props.slices.reduce((a, b) => a + b.value, 0))
|
||||||
|
|
||||||
|
async function ensureEcharts() {
|
||||||
|
if (echartsMod) return echartsMod
|
||||||
|
const core = await import('echarts/core')
|
||||||
|
const { PieChart } = await import('echarts/charts')
|
||||||
|
const { TooltipComponent, LegendComponent } = await import('echarts/components')
|
||||||
|
const { CanvasRenderer } = await import('echarts/renderers')
|
||||||
|
core.use([PieChart, TooltipComponent, LegendComponent, CanvasRenderer])
|
||||||
|
echartsMod = core
|
||||||
|
return core
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssVar(name: string, fb: string): string {
|
||||||
|
return getComputedStyle(document.body).getPropertyValue(name).trim() || fb
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChartColors(): string[] {
|
||||||
|
return ['--chart-1', '--chart-2', '--chart-3', '--chart-4', '--red'].map(v => cssVar(v, '')) || ['#2563EB', '#7C3AED', '#10B981', '#D97706', '#DC2626']
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!el.value || !chart) return
|
||||||
|
const ink = cssVar('--ink-3', '#64748B')
|
||||||
|
const border = cssVar('--border', '#E2E8F0')
|
||||||
|
const surface = cssVar('--surface', '#FFFFFF')
|
||||||
|
const colors = getChartColors()
|
||||||
|
chart.setOption({
|
||||||
|
tooltip: { trigger: 'item', backgroundColor: surface, borderColor: border, textStyle: { color: ink } },
|
||||||
|
legend: { bottom: 0, textStyle: { color: ink } },
|
||||||
|
series: [{
|
||||||
|
type: 'pie', radius: ['58%', '80%'], center: ['50%', '45%'],
|
||||||
|
avoidLabelOverlap: true,
|
||||||
|
label: { show: false }, labelLine: { show: false },
|
||||||
|
data: props.slices.map((s, i) => ({ name: s.name, value: s.value, itemStyle: { color: colors[i % colors.length] } })),
|
||||||
|
emphasis: { label: { show: true, fontSize: 14, fontWeight: 600, color: ink } }
|
||||||
|
}],
|
||||||
|
graphic: total.value > 0 ? [
|
||||||
|
{ type: 'text', left: 'center', top: '38%', style: { text: '总计', fontSize: 10, fill: ink, opacity: 0.5 } },
|
||||||
|
{ type: 'text', left: 'center', top: '45%', style: { text: String(total.value), fontSize: 24, fontWeight: 700, fill: ink } }
|
||||||
|
] : []
|
||||||
|
}, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResize() { chart?.resize() }
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const mod = await ensureEcharts()
|
||||||
|
if (el.value) {
|
||||||
|
chart = mod.init(el.value)
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
} catch { /* 懒加载失败静默 */ }
|
||||||
|
window.addEventListener('resize', onResize)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.slices, themeStore.current],
|
||||||
|
() => render(),
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', onResize)
|
||||||
|
chart?.dispose()
|
||||||
|
chart = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="el" class="rr-chart"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rr-chart { width: 100%; height: 300px; }
|
||||||
|
</style>
|
||||||
89
src/modules/statistics/charts/EchartsTrendChart.vue
Normal file
89
src/modules/statistics/charts/EchartsTrendChart.vue
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 统计页 - 订单趋势折线图 (2026-08-18 从 StatisticsView 抽出)
|
||||||
|
* echarts 动态 import → 独立 chunk 懒加载 (StatisticsView 主 chunk 减重)
|
||||||
|
* 主题/数据变化自动重绘
|
||||||
|
*/
|
||||||
|
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
|
||||||
|
defineOptions({ name: 'EchartsTrendChart' })
|
||||||
|
|
||||||
|
const props = defineProps<{ dates: string[]; counts: number[]; amounts: number[] }>()
|
||||||
|
|
||||||
|
const el = ref<HTMLDivElement | null>(null)
|
||||||
|
let chart: any = null
|
||||||
|
let echartsMod: any = null
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
|
||||||
|
/** 动态加载 echarts (独立 chunk, 不随主 bundle) */
|
||||||
|
async function ensureEcharts() {
|
||||||
|
if (echartsMod) return echartsMod
|
||||||
|
const core = await import('echarts/core')
|
||||||
|
const { LineChart } = await import('echarts/charts')
|
||||||
|
const { TooltipComponent, GridComponent, LegendComponent } = await import('echarts/components')
|
||||||
|
const { CanvasRenderer } = await import('echarts/renderers')
|
||||||
|
core.use([LineChart, TooltipComponent, GridComponent, LegendComponent, CanvasRenderer])
|
||||||
|
echartsMod = core
|
||||||
|
return core
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssVar(name: string, fb: string): string {
|
||||||
|
return getComputedStyle(document.body).getPropertyValue(name).trim() || fb
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!el.value || !chart) return
|
||||||
|
const ink = cssVar('--ink-3', '#64748B')
|
||||||
|
const border = cssVar('--border', '#E2E8F0')
|
||||||
|
const surface = cssVar('--surface', '#FFFFFF')
|
||||||
|
const brand = cssVar('--brand', '#2563EB')
|
||||||
|
const chart2 = cssVar('--chart-2', '#7C3AED')
|
||||||
|
chart.setOption({
|
||||||
|
grid: { left: 50, right: 50, top: 40, bottom: 30 },
|
||||||
|
tooltip: { trigger: 'axis', backgroundColor: surface, borderColor: border, textStyle: { color: ink } },
|
||||||
|
legend: { top: 0, textStyle: { color: ink } },
|
||||||
|
xAxis: { type: 'category', data: props.dates, axisLine: { lineStyle: { color: border } }, axisLabel: { color: ink } },
|
||||||
|
yAxis: [
|
||||||
|
{ type: 'value', name: '订单数', position: 'left', splitLine: { lineStyle: { color: border } }, axisLabel: { color: ink } },
|
||||||
|
{ type: 'value', name: '交易额', position: 'right', splitLine: { show: false }, axisLabel: { color: ink, formatter: (v: number) => v >= 1000 ? `${(v / 1000).toFixed(0)}K` : String(v) } }
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{ name: '订单数', type: 'line', data: props.counts, smooth: true, itemStyle: { color: brand }, lineStyle: { color: brand, width: 2 }, areaStyle: { color: brand, opacity: 0.15 } },
|
||||||
|
{ name: '交易额', type: 'line', yAxisIndex: 1, data: props.amounts, smooth: true, itemStyle: { color: chart2 }, lineStyle: { color: chart2, width: 2 } }
|
||||||
|
]
|
||||||
|
}, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResize() { chart?.resize() }
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const mod = await ensureEcharts()
|
||||||
|
if (el.value) {
|
||||||
|
chart = mod.init(el.value)
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
} catch { /* echarts 懒加载失败时静默 (图表留空) */ }
|
||||||
|
window.addEventListener('resize', onResize)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.dates, props.counts, props.amounts, themeStore.current],
|
||||||
|
() => render()
|
||||||
|
)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', onResize)
|
||||||
|
chart?.dispose()
|
||||||
|
chart = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="el" class="rr-chart"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rr-chart { width: 100%; height: 300px; }
|
||||||
|
</style>
|
||||||
@ -164,7 +164,7 @@ onMounted(() => { load() })
|
|||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-dialog v-model="detailVisible" title="活动详情" width="600" :style="{ borderRadius: '12px', overflow: 'hidden' }" align-center destroy-on-close>
|
<el-dialog v-model="detailVisible" title="活动详情" width="640px" :style="{ borderRadius: '12px', overflow: 'hidden' }" align-center destroy-on-close>
|
||||||
<template v-if="detailRow">
|
<template v-if="detailRow">
|
||||||
<el-descriptions :column="2" border size="default">
|
<el-descriptions :column="2" border size="default">
|
||||||
<el-descriptions-item label="活动名称" :span="2">{{ detailRow.activityName }}</el-descriptions-item>
|
<el-descriptions-item label="活动名称" :span="2">{{ detailRow.activityName }}</el-descriptions-item>
|
||||||
|
|||||||
@ -168,9 +168,9 @@ onMounted(load)
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="满减配置" min-width="180" show-overflow-tooltip>
|
<el-table-column label="满减配置" min-width="180" show-overflow-tooltip>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="(row.type === 1 ? 'warning' : 'success') as any" size="small" effect="light">
|
<span class="st-pill" :style="row.type === 1 ? { color: 'var(--st-warn-fg)', background: 'var(--st-warn-bg)' } : { color: 'var(--st-ok-fg)', background: 'var(--st-ok-bg)' }">
|
||||||
{{ fmtConfig(row) }}
|
<span class="st-dot"></span>{{ fmtConfig(row) }}
|
||||||
</el-tag>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="适用商品" min-width="180" show-overflow-tooltip>
|
<el-table-column label="适用商品" min-width="180" show-overflow-tooltip>
|
||||||
@ -191,9 +191,9 @@ onMounted(load)
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="90" align="center">
|
<el-table-column label="状态" width="90" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="(row.onShelf === 1 ? 'success' : 'info') as any" size="small" effect="light">
|
<span class="st-pill" :style="row.onShelf === 1 ? { color: 'var(--st-ok-fg)', background: 'var(--st-ok-bg)' } : { color: 'var(--st-lv4-fg)', background: 'var(--st-lv4-bg)' }">
|
||||||
{{ row.onShelf === 1 ? '启用' : '停用' }}
|
<span class="st-dot"></span>{{ row.onShelf === 1 ? '启用' : '停用' }}
|
||||||
</el-tag>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="createTime" label="创建时间" width="180" />
|
<el-table-column prop="createTime" label="创建时间" width="180" />
|
||||||
@ -216,14 +216,14 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-dialog v-model="recordVisible" title="优惠券领取记录" width="720px" :close-on-click-modal="false">
|
<el-dialog v-model="recordVisible" title="优惠券领取记录" width="800px" :close-on-click-modal="false">
|
||||||
<el-table v-loading="recordLoading" :data="recordList" border style="width: 100%" empty-text="暂无人领取">
|
<el-table v-loading="recordLoading" :data="recordList" border style="width: 100%" empty-text="暂无人领取">
|
||||||
<el-table-column prop="userName" label="领取人" align="center" />
|
<el-table-column prop="userName" label="领取人" align="center" />
|
||||||
<el-table-column label="状态" align="center">
|
<el-table-column label="状态" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag v-if="row.status === 1" size="small" type="info" effect="light">已使用</el-tag>
|
<span v-if="row.status === 1" class="st-pill" style="color: var(--st-lv4-fg); background: var(--st-lv4-bg)"><span class="st-dot"></span>已使用</span>
|
||||||
<el-tag v-else-if="row.status === 2" size="small" type="success" effect="light">未使用</el-tag>
|
<span v-else-if="row.status === 2" class="st-pill" style="color: var(--st-ok-fg); background: var(--st-ok-bg)"><span class="st-dot"></span>未使用</span>
|
||||||
<el-tag v-else size="small" type="warning" effect="light">未知</el-tag>
|
<span v-else class="st-pill" style="color: var(--st-warn-fg); background: var(--st-warn-bg)"><span class="st-dot"></span>未知</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="createTime" label="领取时间" align="center" width="200" />
|
<el-table-column prop="createTime" label="领取时间" align="center" width="200" />
|
||||||
@ -233,7 +233,7 @@ onMounted(load)
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog v-model="editVisible" :title="editMode === 'add' ? '新增优惠券' : '编辑优惠券'" width="520px">
|
<el-dialog v-model="editVisible" :title="editMode === 'add' ? '新增优惠券' : '编辑优惠券'" width="500px">
|
||||||
<el-form label-width="100px">
|
<el-form label-width="100px">
|
||||||
<el-form-item label="券名称" required><el-input v-model="editForm.name" placeholder="如: 满100减20" /></el-form-item>
|
<el-form-item label="券名称" required><el-input v-model="editForm.name" placeholder="如: 满100减20" /></el-form-item>
|
||||||
<el-form-item label="类型">
|
<el-form-item label="类型">
|
||||||
|
|||||||
@ -60,15 +60,15 @@ interface SkuItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 状态映射 (沿用老 vue checkStatus 1=待审核 2=已通过 3=已驳回, onShelf 0=下架 1=在售)
|
// 状态映射 (沿用老 vue checkStatus 1=待审核 2=已通过 3=已驳回, onShelf 0=下架 1=在售)
|
||||||
const CHECK_STATUS_MAP: Record<number, { label: string; type: string }> = {
|
const CHECK_STATUS_MAP: Record<number, { label: string; color: string; bg: string }> = {
|
||||||
1: { label: '待审核', type: 'warning' },
|
1: { label: '待审核', color: 'var(--st-warn-fg)', bg: 'var(--st-warn-bg)' },
|
||||||
2: { label: '已通过', type: 'success' },
|
2: { label: '已通过', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
|
||||||
3: { label: '已驳回', type: 'danger' }
|
3: { label: '已驳回', color: 'var(--st-err-fg)', bg: 'var(--st-err-bg)' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const ONSHELF_STATUS_MAP: Record<number, { label: string; type: string }> = {
|
const ONSHELF_STATUS_MAP: Record<number, { label: string; color: string; bg: string }> = {
|
||||||
0: { label: '已下架', type: 'info' },
|
0: { label: '已下架', color: 'var(--st-lv4-fg)', bg: 'var(--st-lv4-bg)' },
|
||||||
1: { label: '已上架', type: 'success' }
|
1: { label: '已上架', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_TABS = [
|
const STATUS_TABS = [
|
||||||
@ -121,12 +121,14 @@ const filters = reactive({
|
|||||||
checkStatus: ''
|
checkStatus: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
// 弹窗
|
// 商品详情/审核抽屉 (2026-09-18 重设计: 大抽屉分区展示 完整信息 + 审核操作条)
|
||||||
const detailVisible = ref(false)
|
const drawerVisible = ref(false)
|
||||||
const detailData = ref<Good | null>(null)
|
const drawerLoading = ref(false)
|
||||||
const checkDialogVisible = ref(false)
|
const drawerRow = ref<Good | null>(null)
|
||||||
const checkData = ref<Good | null>(null)
|
const drawerInfo = ref<any>(null)
|
||||||
const checkForm = reactive({ checkStatus: 2, checkOpinion: '' })
|
const drawerFocusAudit = ref(false)
|
||||||
|
const drawerAuditForm = reactive({ checkStatus: 2, checkOpinion: '' })
|
||||||
|
const galleryActive = ref(0)
|
||||||
const categoryDialogVisible = ref(false)
|
const categoryDialogVisible = ref(false)
|
||||||
const categoryData = ref<Good | null>(null)
|
const categoryData = ref<Good | null>(null)
|
||||||
const categoryForm = reactive({ firstCategoryId: '', secondCategoryId: '', thirdCategoryId: '' })
|
const categoryForm = reactive({ firstCategoryId: '', secondCategoryId: '', thirdCategoryId: '' })
|
||||||
@ -134,6 +136,12 @@ const batchCategoryDialogVisible = ref(false)
|
|||||||
const batchRejectDialogVisible = ref(false)
|
const batchRejectDialogVisible = ref(false)
|
||||||
const batchRejectForm = reactive({ reason: '' })
|
const batchRejectForm = reactive({ reason: '' })
|
||||||
|
|
||||||
|
// httpGet/httpPost 已解包 code===0 的业务 data(拦截器已 reject 非 0 code),
|
||||||
|
// 拿到值即为成功;兼容对象/布尔/null 返回(与 InquiryDetailView 修复同源)
|
||||||
|
function isOk(r: any) {
|
||||||
|
return !(r && typeof r === 'object' && 'code' in r && Number(r.code) !== 0)
|
||||||
|
}
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ type: 'selection' as const, label: '', width: 50 },
|
{ type: 'selection' as const, label: '', width: 50 },
|
||||||
{ prop: 'supplierGoodId', label: '商品ID', width: 100 },
|
{ prop: 'supplierGoodId', label: '商品ID', width: 100 },
|
||||||
@ -188,13 +196,17 @@ async function load() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
// W13.1: /supplier/good/pageList 返完整字段 (16133 条) — 比 search 端点更全
|
// W13.1: /supplier/good/pageList 返完整字段 — 比 search 端点更全
|
||||||
const data = await httpGet<{ total: number | string; list: any[] }>('/supplier/good/pageList', {
|
// 2026-09-20: 筛选条件下推到后端 (原仅前端过滤当前页, 导致 tab 计数 42 与列表 16106 条不一致)
|
||||||
|
const queryParams: Record<string, any> = {
|
||||||
page: page.value,
|
page: page.value,
|
||||||
limit: limit.value,
|
limit: limit.value,
|
||||||
type: 1, // 1=实物商品 (2=服务商品, 在 ServiceGoodListView 单独展示)
|
type: 1 // 1=实物商品 (2=服务商品, 在 ServiceGoodListView 单独展示)
|
||||||
...(filters.title ? { title: filters.title } : {})
|
}
|
||||||
})
|
if (filters.title) queryParams.title = filters.title
|
||||||
|
if (filters.checkStatus) queryParams.checkStatus = Number(filters.checkStatus)
|
||||||
|
if (filters.enterpriseName) queryParams.enterpriseName = filters.enterpriseName
|
||||||
|
const data = await httpGet<{ total: number | string; list: any[] }>('/supplier/good/pageList', queryParams)
|
||||||
let rows = (data.list || []).map((it: any) => ({
|
let rows = (data.list || []).map((it: any) => ({
|
||||||
id: String(it.id),
|
id: String(it.id),
|
||||||
supplierGoodId: it.supplierGoodId || String(it.id),
|
supplierGoodId: it.supplierGoodId || String(it.id),
|
||||||
@ -212,8 +224,7 @@ async function load() {
|
|||||||
createDate: it.createDate || '',
|
createDate: it.createDate || '',
|
||||||
raw: it
|
raw: it
|
||||||
}))
|
}))
|
||||||
if (filters.enterpriseName) rows = rows.filter(r => r.enterpriseName.includes(filters.enterpriseName))
|
// 筛选已由后端条件完成 (checkStatus/enterpriseName/title 下推), rows 即当前页数据
|
||||||
if (filters.checkStatus) rows = rows.filter(r => String(r.checkStatus) === filters.checkStatus)
|
|
||||||
list.value = rows
|
list.value = rows
|
||||||
total.value = Number(data.total) || 0
|
total.value = Number(data.total) || 0
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@ -241,32 +252,57 @@ const tabItems = computed(() => [
|
|||||||
|
|
||||||
function onStatusTab(v: string) { filters.checkStatus = v; page.value = 1; load() }
|
function onStatusTab(v: string) { filters.checkStatus = v; page.value = 1; load() }
|
||||||
|
|
||||||
function onShowDetail(row: Good) { detailData.value = row; detailVisible.value = true }
|
function splitImages(v: any): string[] {
|
||||||
function onShowCheckDialog(row: Good) { checkData.value = row; checkForm.checkStatus = 2; checkForm.checkOpinion = ''; checkDialogVisible.value = true }
|
return String(v || '').split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||||
async function onSubmitCheck() {
|
}
|
||||||
if (!checkForm.checkOpinion.trim()) { ElMessage.warning('请填写审核意见'); return }
|
/** 打开商品详情/审核抽屉 (focusAudit=true 时审核入口进入并定位操作条) */
|
||||||
// W14.3: 真实调 /supplier/good/checkGood?checkStatus=&checkOpinion=
|
async function openGoodDrawer(row: Good, focusAudit = false) {
|
||||||
|
drawerRow.value = row
|
||||||
|
drawerInfo.value = null
|
||||||
|
drawerFocusAudit.value = focusAudit
|
||||||
|
drawerAuditForm.checkStatus = 2
|
||||||
|
drawerAuditForm.checkOpinion = ''
|
||||||
|
galleryActive.value = 0
|
||||||
|
drawerVisible.value = true
|
||||||
|
drawerLoading.value = true
|
||||||
|
try {
|
||||||
|
// 列表行 id 是 tb_good(铺货表) 的 id; 详情接口要 tb_supplier_good.id -> 用行的 supplierGoodId
|
||||||
|
const detailId = Number(row.raw?.supplierGoodId) || Number(row.raw?.supplier_good_id) || Number(row.id)
|
||||||
|
const r: any = await httpGet('/supplier/supplierGood/info', { id: detailId })
|
||||||
|
// httpGet 已解包: r 即 SupplierGoodDTO (含 images/content/skuList/skuDetailList)
|
||||||
|
drawerInfo.value = r && (r.id || r.title) ? r : (row.raw || row)
|
||||||
|
} catch {
|
||||||
|
drawerInfo.value = row.raw || row
|
||||||
|
} finally { drawerLoading.value = false }
|
||||||
|
if (focusAudit) {
|
||||||
|
setTimeout(() => document.querySelector('.rr-gd-audit')?.scrollIntoView({ behavior: 'smooth', block: 'center' }), 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const drawerImages = computed(() => splitImages(drawerInfo.value?.images))
|
||||||
|
const drawerSkuTotalStore = computed(() => (drawerInfo.value?.skuDetailList || []).reduce((s: number, it: any) => s + (Number(it.store) || 0), 0))
|
||||||
|
const drawerCategoryPath = computed(() => [drawerInfo.value?.firstCategoryName, drawerInfo.value?.secondCategoryName, drawerInfo.value?.thirdCategoryName].filter(Boolean).join(' / '))
|
||||||
|
function onShowDetail(row: Good) { openGoodDrawer(row, false) }
|
||||||
|
function onShowCheckDialog(row: Good) { openGoodDrawer(row, true) }
|
||||||
|
async function onSubmitDrawerAudit() {
|
||||||
|
const f = drawerAuditForm
|
||||||
|
if (f.checkStatus === 3 && !f.checkOpinion.trim()) { ElMessage.warning('驳回时请填写审核意见'); return }
|
||||||
|
const id = drawerRow.value?.id || drawerInfo.value?.id
|
||||||
try {
|
try {
|
||||||
const id = checkData.value?.id || checkData.value?.raw?.id
|
|
||||||
const r: any = await httpGet('/supplier/good/checkGood', {
|
const r: any = await httpGet('/supplier/good/checkGood', {
|
||||||
id,
|
id, checkStatus: f.checkStatus, checkOpinion: f.checkOpinion.trim()
|
||||||
checkStatus: checkForm.checkStatus,
|
|
||||||
checkOpinion: checkForm.checkOpinion || ''
|
|
||||||
})
|
})
|
||||||
if (r?.code === 0) {
|
if (isOk(r)) {
|
||||||
ElMessage.success(checkForm.checkStatus === 2 ? '审核通过' : '已驳回')
|
ElMessage.success(f.checkStatus === 2 ? '审核通过' : '已驳回')
|
||||||
checkDialogVisible.value = false
|
drawerVisible.value = false
|
||||||
load()
|
load()
|
||||||
} else {
|
} else { ElMessage.error(r?.msg || '操作失败') }
|
||||||
ElMessage.error(r?.msg || '操作失败')
|
|
||||||
}
|
|
||||||
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
|
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
|
||||||
}
|
}
|
||||||
async function onOnShelf(row: Good) {
|
async function onOnShelf(row: Good) {
|
||||||
try { await ElMessageBox.confirm(`确认${row.onShelf === 1 ? '下架' : '上架'}「${row.title}」?`, '提示', { type: 'warning' }) } catch { return }
|
try { await ElMessageBox.confirm(`确认${row.onShelf === 1 ? '下架' : '上架'}「${row.title}」?`, '提示', { type: 'warning' }) } catch { return }
|
||||||
try {
|
try {
|
||||||
const r: any = await httpGet('/supplier/good/onShelfGood', { id: row.id })
|
const r: any = await httpGet('/supplier/good/onShelfGood', { id: row.id })
|
||||||
if (r?.code === 0) { ElMessage.success(row.onShelf === 1 ? '已下架' : '已上架'); load() }
|
if (isOk(r)) { ElMessage.success(row.onShelf === 1 ? '已下架' : '已上架'); load() }
|
||||||
else ElMessage.error(r?.msg || '操作失败')
|
else ElMessage.error(r?.msg || '操作失败')
|
||||||
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
|
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
|
||||||
}
|
}
|
||||||
@ -279,7 +315,7 @@ async function onSubmitCategory() {
|
|||||||
secondCategoryId: categoryForm.secondCategoryId || undefined,
|
secondCategoryId: categoryForm.secondCategoryId || undefined,
|
||||||
thirdCategoryId: categoryForm.thirdCategoryId || undefined
|
thirdCategoryId: categoryForm.thirdCategoryId || undefined
|
||||||
})
|
})
|
||||||
if (r?.code === 0) { ElMessage.success('分类已修改'); categoryDialogVisible.value = false; load() }
|
if (isOk(r)) { ElMessage.success('分类已修改'); categoryDialogVisible.value = false; load() }
|
||||||
else ElMessage.error(r?.msg || '操作失败')
|
else ElMessage.error(r?.msg || '操作失败')
|
||||||
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
|
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
|
||||||
}
|
}
|
||||||
@ -287,7 +323,7 @@ async function onDelete(row: Good) {
|
|||||||
try { await ElMessageBox.confirm(`确认删除「${row.title}」?`, '提示', { type: 'warning' }) } catch { return }
|
try { await ElMessageBox.confirm(`确认删除「${row.title}」?`, '提示', { type: 'warning' }) } catch { return }
|
||||||
try {
|
try {
|
||||||
const r: any = await httpGet('/supplier/good/delete', { id: row.id })
|
const r: any = await httpGet('/supplier/good/delete', { id: row.id })
|
||||||
if (r?.code === 0) { ElMessage.success('已删除'); load() }
|
if (isOk(r)) { ElMessage.success('已删除'); load() }
|
||||||
else ElMessage.error(r?.msg || '操作失败')
|
else ElMessage.error(r?.msg || '操作失败')
|
||||||
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
|
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
|
||||||
}
|
}
|
||||||
@ -385,7 +421,7 @@ onMounted(() => Promise.all([load(), loadStats(), loadCounts()]))
|
|||||||
<template #title="{ row }">
|
<template #title="{ row }">
|
||||||
<div class="rr-good-cell">
|
<div class="rr-good-cell">
|
||||||
<div v-if="row.icon" class="rr-good-icon">
|
<div v-if="row.icon" class="rr-good-icon">
|
||||||
<el-image :src="row.icon" :preview-src-list="[row.icon]" fit="cover" style="width: 40px; height: 40px; border-radius: 4px" />
|
<el-image :src="row.icon" :preview-src-list="[row.icon]" fit="cover" style="width: 40px; height: 40px; border-radius: 4px" preview-teleported />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="rr-good-icon-placeholder">{{ (row.title || '?').charAt(0) }}</div>
|
<div v-else class="rr-good-icon-placeholder">{{ (row.title || '?').charAt(0) }}</div>
|
||||||
<span class="rr-good-title">{{ row.title }}</span>
|
<span class="rr-good-title">{{ row.title }}</span>
|
||||||
@ -395,14 +431,10 @@ onMounted(() => Promise.all([load(), loadStats(), loadCounts()]))
|
|||||||
<span>{{ [row.firstCategoryName, row.secondCategoryName, row.thirdCategoryName].filter(Boolean).join(' / ') || '--' }}</span>
|
<span>{{ [row.firstCategoryName, row.secondCategoryName, row.thirdCategoryName].filter(Boolean).join(' / ') || '--' }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template #onShelf="{ row }">
|
<template #onShelf="{ row }">
|
||||||
<el-tag :type="(ONSHELF_STATUS_MAP[row.onShelf]?.type as any)" size="small" effect="light">
|
<span class="st-pill" :style="{ color: ONSHELF_STATUS_MAP[row.onShelf]?.color, background: ONSHELF_STATUS_MAP[row.onShelf]?.bg }"><span class="st-dot"></span>{{ ONSHELF_STATUS_MAP[row.onShelf]?.label }}</span>
|
||||||
{{ ONSHELF_STATUS_MAP[row.onShelf]?.label }}
|
|
||||||
</el-tag>
|
|
||||||
</template>
|
</template>
|
||||||
<template #checkStatus="{ row }">
|
<template #checkStatus="{ row }">
|
||||||
<el-tag :type="(CHECK_STATUS_MAP[row.checkStatus]?.type as any)" size="small" effect="light">
|
<span class="st-pill" :style="{ color: CHECK_STATUS_MAP[row.checkStatus]?.color, background: CHECK_STATUS_MAP[row.checkStatus]?.bg }"><span class="st-dot"></span>{{ CHECK_STATUS_MAP[row.checkStatus]?.label }}</span>
|
||||||
{{ CHECK_STATUS_MAP[row.checkStatus]?.label }}
|
|
||||||
</el-tag>
|
|
||||||
</template>
|
</template>
|
||||||
<template #action="{ row }">
|
<template #action="{ row }">
|
||||||
<TableActions
|
<TableActions
|
||||||
@ -434,7 +466,7 @@ onMounted(() => Promise.all([load(), loadStats(), loadCounts()]))
|
|||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="rr-good-cell">
|
<div class="rr-good-cell">
|
||||||
<div v-if="row.icon" class="rr-good-icon">
|
<div v-if="row.icon" class="rr-good-icon">
|
||||||
<el-image :src="row.icon" :preview-src-list="[row.icon]" fit="cover" style="width: 40px; height: 40px; border-radius: 4px" />
|
<el-image :src="row.icon" :preview-src-list="[row.icon]" fit="cover" style="width: 40px; height: 40px; border-radius: 4px" preview-teleported />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="rr-good-icon-placeholder">{{ (row.title || '?').charAt(0) }}</div>
|
<div v-else class="rr-good-icon-placeholder">{{ (row.title || '?').charAt(0) }}</div>
|
||||||
<span class="rr-good-title">{{ row.title }}</span>
|
<span class="rr-good-title">{{ row.title }}</span>
|
||||||
@ -451,17 +483,13 @@ onMounted(() => Promise.all([load(), loadStats(), loadCounts()]))
|
|||||||
<el-table-column prop="unitName" label="单位" width="80" align="center" />
|
<el-table-column prop="unitName" label="单位" width="80" align="center" />
|
||||||
<el-table-column label="铺货" width="100" align="center">
|
<el-table-column label="铺货" width="100" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="(ONSHELF_STATUS_MAP[row.onShelf]?.type as any)" size="small" effect="light">
|
<span class="st-pill" :style="{ color: ONSHELF_STATUS_MAP[row.onShelf]?.color, background: ONSHELF_STATUS_MAP[row.onShelf]?.bg }"><span class="st-dot"></span>{{ ONSHELF_STATUS_MAP[row.onShelf]?.label }}</span>
|
||||||
{{ ONSHELF_STATUS_MAP[row.onShelf]?.label }}
|
|
||||||
</el-tag>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="checkOpinion" label="审核意见" min-width="160" show-overflow-tooltip />
|
<el-table-column prop="checkOpinion" label="审核意见" min-width="160" show-overflow-tooltip />
|
||||||
<el-table-column label="状态" width="100" align="center">
|
<el-table-column label="状态" width="100" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="(CHECK_STATUS_MAP[row.checkStatus]?.type as any)" size="small" effect="light">
|
<span class="st-pill" :style="{ color: CHECK_STATUS_MAP[row.checkStatus]?.color, background: CHECK_STATUS_MAP[row.checkStatus]?.bg }"><span class="st-dot"></span>{{ CHECK_STATUS_MAP[row.checkStatus]?.label }}</span>
|
||||||
{{ CHECK_STATUS_MAP[row.checkStatus]?.label }}
|
|
||||||
</el-tag>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="申请时间" prop="createDate" width="170" />
|
<el-table-column label="申请时间" prop="createDate" width="170" />
|
||||||
@ -479,78 +507,141 @@ onMounted(() => Promise.all([load(), loadStats(), loadCounts()]))
|
|||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 详情弹窗 (含 SKU 列表, 沿用老 vue 弹窗布局) -->
|
<!-- ===== 商品详情 / 审核抽屉 (2026-09-18 重设计: 分区式完整信息 + 底部审核操作条) ===== -->
|
||||||
<el-dialog v-model="detailVisible" title="商品详情" width="900px" :close-on-click-modal="false">
|
<el-drawer v-model="drawerVisible" size="min(940px, 94vw)" :with-header="true"
|
||||||
<div v-if="detailData">
|
:title="drawerRow?.title ? `商品详情 · ${drawerRow.title}` : '商品详情'"
|
||||||
<el-descriptions :column="2" border>
|
destroy-on-close :close-on-click-modal="false" class="rr-gd-drawer">
|
||||||
<el-descriptions-item label="商品ID">{{ detailData.supplierGoodId }}</el-descriptions-item>
|
<div v-loading="drawerLoading" class="rr-gd-wrap">
|
||||||
<el-descriptions-item label="商品名称" :span="2">{{ detailData.title }}</el-descriptions-item>
|
<template v-if="drawerInfo">
|
||||||
<el-descriptions-item label="供应商">{{ detailData.enterpriseName }}</el-descriptions-item>
|
<!-- 头部: 主图 + 标题/标签 + 元信息 + 价格 -->
|
||||||
<el-descriptions-item label="品牌">{{ detailData.brandName }}</el-descriptions-item>
|
<div class="rr-gd-head">
|
||||||
<el-descriptions-item label="分类" :span="2">
|
<el-image :src="drawerInfo.icon || drawerImages[0]" fit="cover"
|
||||||
{{ [detailData.firstCategoryName, detailData.secondCategoryName, detailData.thirdCategoryName].filter(Boolean).join(' / ') }}
|
:preview-src-list="[drawerInfo.icon || drawerImages[0]].filter(Boolean)" preview-teleported
|
||||||
</el-descriptions-item>
|
class="rr-gd-mainimg">
|
||||||
<el-descriptions-item label="状态">
|
<template #error><div class="rr-gd-imgerr">无图</div></template>
|
||||||
<el-tag :type="(CHECK_STATUS_MAP[detailData.checkStatus]?.type as any)" size="small">
|
</el-image>
|
||||||
{{ CHECK_STATUS_MAP[detailData.checkStatus]?.label }}
|
<div class="rr-gd-headmain">
|
||||||
</el-tag>
|
<div class="rr-gd-titleline">
|
||||||
</el-descriptions-item>
|
<span class="rr-gd-title">{{ drawerInfo.title || drawerRow?.title }}
|
||||||
<el-descriptions-item label="铺货">
|
<span v-if="Number(drawerInfo.sourceType) === 2" class="px-1.5 py-0.5 bg-amber-50 text-amber-600 border border-amber-200 rounded text-[10px] font-bold align-middle" style="margin-left:6px">共享商品</span>
|
||||||
<el-tag :type="(ONSHELF_STATUS_MAP[detailData.onShelf]?.type as any)" size="small">
|
</span>
|
||||||
{{ ONSHELF_STATUS_MAP[detailData.onShelf]?.label }}
|
<span class="rr-gd-code">{{ drawerInfo.goodCode || drawerInfo.id }}</span>
|
||||||
</el-tag>
|
</div>
|
||||||
</el-descriptions-item>
|
<div class="rr-gd-tags">
|
||||||
<el-descriptions-item label="审核意见" :span="2">{{ detailData.checkOpinion || '--' }}</el-descriptions-item>
|
<span class="st-pill" :style="{ color: CHECK_STATUS_MAP[Number(drawerRow?.checkStatus ?? -1)]?.color, background: CHECK_STATUS_MAP[Number(drawerRow?.checkStatus ?? -1)]?.bg }"><span class="st-dot"></span>{{ (drawerRow ? CHECK_STATUS_MAP[drawerRow.checkStatus] : undefined)?.label || '--' }}</span>
|
||||||
</el-descriptions>
|
<span class="st-pill" :style="{ color: ONSHELF_STATUS_MAP[Number(drawerRow?.onShelf ?? -1)]?.color, background: ONSHELF_STATUS_MAP[Number(drawerRow?.onShelf ?? -1)]?.bg }"><span class="st-dot"></span>{{ ONSHELF_STATUS_MAP[Number(drawerRow?.onShelf ?? -1)]?.label || '--' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="rr-gd-price">
|
||||||
|
<span class="rr-gd-price-now">¥ {{ drawerInfo.newPrice ?? drawerRow?.raw?.newPrice ?? '--' }}</span>
|
||||||
|
<span v-if="drawerInfo.oldPrice" class="rr-gd-price-old">¥ {{ drawerInfo.oldPrice }}</span>
|
||||||
|
<span class="rr-gd-price-unit">/ {{ drawerInfo.unitName || drawerRow?.unitName || '件' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="rr-gd-meta">
|
||||||
|
<span><label>供应商</label>{{ drawerInfo.enterpriseName || drawerRow?.enterpriseName || '--' }}</span>
|
||||||
|
<span><label>品牌</label>{{ drawerInfo.brandName || drawerRow?.brandName || '--' }}</span>
|
||||||
|
<span><label>分类</label>{{ drawerCategoryPath || '--' }}</span>
|
||||||
|
<span><label>申请时间</label>{{ drawerInfo.createDate || drawerRow?.createDate || '--' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h4 class="rr-sku-title">SKU 列表 ({{ detailData.skuList?.length || 0 }})</h4>
|
<!-- 商品图片画廊 -->
|
||||||
<el-table v-if="detailData.skuList && detailData.skuList.length > 0" :data="detailData.skuList" border>
|
<div class="rr-gd-section" v-if="drawerImages.length">
|
||||||
<el-table-column label="SKU编码" prop="skuCode" min-width="140" show-overflow-tooltip />
|
<h4 class="rr-gd-sectitle">商品图片 <span class="rr-gd-seccount">{{ drawerImages.length }} 张</span></h4>
|
||||||
<el-table-column label="SKU组合" prop="propertyNames" min-width="200" show-overflow-tooltip />
|
<div class="rr-gd-gallery">
|
||||||
<el-table-column label="原价" width="100" align="right">
|
<el-image v-for="(img, gi) in drawerImages" :key="gi" :src="img" fit="cover"
|
||||||
<template #default="{ row }">¥ {{ row.oldPrice?.toLocaleString() }}</template>
|
:preview-src-list="drawerImages" :initial-index="gi" preview-teleported hide-on-click-modal
|
||||||
</el-table-column>
|
class="rr-gd-thumb">
|
||||||
<el-table-column label="现价" width="100" align="right">
|
<template #error><div class="rr-gd-imgerr">加载失败</div></template>
|
||||||
<template #default="{ row }">¥ {{ row.newPrice?.toLocaleString() }}</template>
|
</el-image>
|
||||||
</el-table-column>
|
</div>
|
||||||
<el-table-column label="秒杀价" width="100" align="right">
|
</div>
|
||||||
<template #default="{ row }">
|
|
||||||
<span v-if="row.seckillPrice">¥ {{ row.seckillPrice.toLocaleString() }}</span>
|
<!-- 介绍视频 -->
|
||||||
<span v-else>--</span>
|
<div class="rr-gd-section" v-if="drawerInfo.videoUrl">
|
||||||
</template>
|
<h4 class="rr-gd-sectitle">介绍视频</h4>
|
||||||
</el-table-column>
|
<video :src="drawerInfo.videoUrl" controls preload="metadata" class="rr-gd-video"></video>
|
||||||
<el-table-column label="拼团价" width="100" align="right">
|
</div>
|
||||||
<template #default="{ row }">
|
|
||||||
<span v-if="row.groupPrice">¥ {{ row.groupPrice.toLocaleString() }}</span>
|
<!-- 规格与 SKU -->
|
||||||
<span v-else>--</span>
|
<div class="rr-gd-section" v-if="(drawerInfo.skuList?.length || drawerInfo.skuDetailList?.length)">
|
||||||
</template>
|
<h4 class="rr-gd-sectitle">规格与 SKU <span class="rr-gd-seccount">{{ drawerInfo.skuDetailList?.length || 0 }} 个 SKU · 库存合计 {{ drawerSkuTotalStore }}</span></h4>
|
||||||
</el-table-column>
|
<div v-if="drawerInfo.skuList?.length" class="rr-gd-props">
|
||||||
<el-table-column label="库存" prop="store" width="80" align="center" />
|
<div v-for="grp in drawerInfo.skuList" :key="grp.id" class="rr-gd-prop">
|
||||||
<el-table-column label="最小购买" prop="buyMin" width="100" align="center" />
|
<label>{{ grp.name }}</label>
|
||||||
</el-table>
|
<span class="rr-gd-propchip" v-for="pv in grp.propertyList" :key="pv.id">{{ pv.name }}</span>
|
||||||
<div v-else class="rr-empty-tip">暂无 SKU</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-table v-if="drawerInfo.skuDetailList?.length" :data="drawerInfo.skuDetailList" border size="small"
|
||||||
|
max-height="320" class="rr-gd-skutable">
|
||||||
|
<el-table-column label="规格组合" prop="propertyNames" min-width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column label="图片" width="64" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-image v-if="row.icon" :src="row.icon" fit="cover" :preview-src-list="[row.icon]" preview-teleported class="rr-gd-skuimg" />
|
||||||
|
<span v-else>--</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="SKU编码" prop="skuCode" min-width="150" show-overflow-tooltip />
|
||||||
|
<el-table-column label="现价" width="96" align="right">
|
||||||
|
<template #default="{ row }">¥ {{ row.newPrice ?? '--' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="原价" width="96" align="right">
|
||||||
|
<template #default="{ row }">{{ row.oldPrice ?? '--' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="秒杀价" width="96" align="right">
|
||||||
|
<template #default="{ row }">{{ row.seckillPrice ?? '--' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="拼团价" width="96" align="right">
|
||||||
|
<template #default="{ row }">{{ row.groupPrice ?? '--' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="库存" width="72" align="center" prop="store" />
|
||||||
|
<el-table-column label="起购" width="64" align="center" prop="buyMin" />
|
||||||
|
<el-table-column label="限购" width="64" align="center">
|
||||||
|
<template #default="{ row }">{{ row.limitNum ?? '--' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 商品详情 (富文本) -->
|
||||||
|
<div class="rr-gd-section">
|
||||||
|
<h4 class="rr-gd-sectitle">商品详情</h4>
|
||||||
|
<div v-if="drawerInfo.content" class="rr-gd-content" v-html="drawerInfo.content"></div>
|
||||||
|
<div v-else-if="drawerInfo.remark" class="rr-gd-content">{{ drawerInfo.remark }}</div>
|
||||||
|
<div v-else class="rr-gd-emptypar">商家未填写图文详情</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 审核信息 -->
|
||||||
|
<div class="rr-gd-section rr-gd-auditinfo">
|
||||||
|
<h4 class="rr-gd-sectitle">审核信息</h4>
|
||||||
|
<div class="rr-gd-auditline">
|
||||||
|
<span><label>当前状态</label>{{ (drawerRow ? CHECK_STATUS_MAP[drawerRow.checkStatus] : undefined)?.label || '--' }}</span>
|
||||||
|
<span><label>审核意见</label>{{ drawerRow?.checkOpinion || drawerInfo?.checkOpinion || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</el-dialog>
|
|
||||||
|
|
||||||
<!-- 审核弹窗 -->
|
<!-- 底部固定审核操作条 -->
|
||||||
<el-dialog v-model="checkDialogVisible" title="商品审核" width="500px">
|
|
||||||
<el-form v-if="checkData" label-width="100px">
|
|
||||||
<el-form-item label="商品名称">{{ checkData.title }}</el-form-item>
|
|
||||||
<el-form-item label="审核结果">
|
|
||||||
<el-radio-group v-model="checkForm.checkStatus">
|
|
||||||
<el-radio :value="2">通过</el-radio>
|
|
||||||
<el-radio :value="3">驳回</el-radio>
|
|
||||||
</el-radio-group>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="审核意见" required>
|
|
||||||
<el-input v-model="checkForm.checkOpinion" type="textarea" :rows="4" placeholder="请填写审核意见" />
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="checkDialogVisible = false">取消</el-button>
|
<!-- 待审核判断以列表行状态为准 (审核入口数据源; 个别脏数据 info 返回值可能不一致) -->
|
||||||
<el-button type="primary" @click="onSubmitCheck">确定</el-button>
|
<div v-if="drawerRow && Number(drawerRow.checkStatus) === 1" class="rr-gd-audit">
|
||||||
|
<el-radio-group v-model="drawerAuditForm.checkStatus" class="rr-gd-auditradio">
|
||||||
|
<el-radio :value="2">审核通过</el-radio>
|
||||||
|
<el-radio :value="3">审核驳回</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
<!-- 通过无需填写审核意见; 驳回时必填 -->
|
||||||
|
<el-input v-if="drawerAuditForm.checkStatus === 3" v-model="drawerAuditForm.checkOpinion" type="textarea"
|
||||||
|
:rows="2" maxlength="200" placeholder="请填写驳回原因(必填),将展示给商家" class="rr-gd-auditinput" />
|
||||||
|
<div class="rr-gd-auditbtns">
|
||||||
|
<el-button @click="drawerVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="onSubmitDrawerAudit">提交审核结果</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="rr-gd-auditdone">
|
||||||
|
<span v-if="drawerRow">该商品已完成审核({{ CHECK_STATUS_MAP[drawerRow.checkStatus]?.label }}<template v-if="drawerInfo?.checkOpinion">:{{ drawerInfo.checkOpinion }}</template>),如需修改请使用列表「⋯ → 上架/下架」操作。</span>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-drawer>
|
||||||
|
|
||||||
<!-- 修改分类弹窗 (单个) -->
|
<!-- 修改分类弹窗 (单个) -->
|
||||||
<el-dialog v-model="categoryDialogVisible" title="修改分类" width="500px">
|
<el-dialog v-model="categoryDialogVisible" title="修改分类" width="500px">
|
||||||
@ -609,4 +700,41 @@ onMounted(() => Promise.all([load(), loadStats(), loadCounts()]))
|
|||||||
.rr-good-title { font-size: 13.5px; color: var(--ink); }
|
.rr-good-title { font-size: 13.5px; color: var(--ink); }
|
||||||
.rr-sku-title { font-size: 14px; font-weight: 600; color: var(--ink); margin: 24px 0 12px 0; }
|
.rr-sku-title { font-size: 14px; font-weight: 600; color: var(--ink); margin: 24px 0 12px 0; }
|
||||||
.rr-empty-tip { text-align: center; padding: 30px 0; color: var(--ink-3); }
|
.rr-empty-tip { text-align: center; padding: 30px 0; color: var(--ink-3); }
|
||||||
|
|
||||||
|
/* ===== 商品详情/审核抽屉 ===== */
|
||||||
|
.rr-gd-wrap { padding: 0 2px; }
|
||||||
|
.rr-gd-head { display: flex; gap: 16px; padding: 4px 0 14px; border-bottom: 1px solid var(--border); }
|
||||||
|
.rr-gd-mainimg { flex: 0 0 96px; width: 96px; height: 96px; border-radius: 12px; border: 1px solid var(--border); background: var(--bg, #f6f8fb); }
|
||||||
|
.rr-gd-imgerr { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; font-size: 12px; color: var(--ink-3); }
|
||||||
|
.rr-gd-headmain { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.rr-gd-titleline { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
|
||||||
|
.rr-gd-title { font-size: 16px; font-weight: 700; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.rr-gd-code { flex: 0 0 auto; font-size: 11px; font-family: 'SF Mono', Menlo, Consolas, monospace; color: var(--ink-3); background: var(--bg, #f6f8fb); border: 1px solid var(--border); border-radius: 4px; padding: 1px 8px; }
|
||||||
|
.rr-gd-tags { display: flex; gap: 8px; }
|
||||||
|
.rr-gd-price { display: flex; align-items: baseline; gap: 8px; }
|
||||||
|
.rr-gd-price-now { font-size: 22px; font-weight: 800; color: #dc2626; }
|
||||||
|
.rr-gd-price-old { font-size: 12px; color: var(--ink-3); text-decoration: line-through; }
|
||||||
|
.rr-gd-price-unit { font-size: 12px; color: var(--ink-3); }
|
||||||
|
.rr-gd-meta { display: flex; flex-wrap: wrap; gap: 6px 18px; font-size: 12px; color: var(--ink-2); }
|
||||||
|
.rr-gd-meta label { color: var(--ink-3); margin-right: 4px; }
|
||||||
|
.rr-gd-section { margin-top: 18px; }
|
||||||
|
.rr-gd-sectitle { font-size: 13px; font-weight: 700; color: var(--ink); margin-bottom: 10px; padding-left: 8px; border-left: 3px solid var(--primary); line-height: 1.2; }
|
||||||
|
.rr-gd-seccount { font-weight: 500; font-size: 11px; color: var(--ink-3); margin-left: 6px; }
|
||||||
|
.rr-gd-gallery { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||||
|
.rr-gd-thumb { width: 92px; height: 92px; border-radius: 10px; border: 1px solid var(--border); cursor: zoom-in; background: var(--bg, #f6f8fb); }
|
||||||
|
.rr-gd-video { max-width: 360px; width: 100%; border-radius: 10px; border: 1px solid var(--border); background: #000; }
|
||||||
|
.rr-gd-props { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
|
||||||
|
.rr-gd-prop { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.rr-gd-prop label { flex: 0 0 88px; font-size: 12px; color: var(--ink-3); }
|
||||||
|
.rr-gd-propchip { font-size: 11px; color: var(--ink-2); background: var(--bg, #f6f8fb); border: 1px solid var(--border); border-radius: 20px; padding: 2px 10px; }
|
||||||
|
.rr-gd-skuimg { width: 40px; height: 40px; border-radius: 6px; border: 1px solid var(--border); cursor: zoom-in; }
|
||||||
|
.rr-gd-content { border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; background: var(--surface); max-height: 460px; overflow-y: auto; }
|
||||||
|
.rr-gd-content :deep(img) { max-width: 100%; height: auto; display: block; margin: 6px 0; border-radius: 6px; }
|
||||||
|
.rr-gd-content :deep(p) { margin: 6px 0; line-height: 1.8; font-size: 13px; color: var(--ink); }
|
||||||
|
.rr-gd-emptypar { font-size: 12px; color: var(--ink-3); background: var(--bg, #f6f8fb); border-radius: 8px; padding: 14px; }
|
||||||
|
.rr-gd-auditinfo .rr-gd-auditline { display: flex; gap: 28px; font-size: 13px; color: var(--ink-2); }
|
||||||
|
.rr-gd-auditinfo label { color: var(--ink-3); margin-right: 6px; }
|
||||||
|
.rr-gd-audit { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.rr-gd-auditradio { margin-bottom: 2px; }
|
||||||
|
.rr-gd-auditdone { font-size: 12px; color: var(--ink-3); background: var(--bg, #f6f8fb); border-radius: 8px; padding: 10px 12px; line-height: 1.6; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,9 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* F006 价格公式变量管理
|
* 价格公式变量管理 (运营后台)
|
||||||
* 走 /supplier/supplierGoodPriceFormulaVariable/page (2026-08-08 探测: 200, total=5)
|
*
|
||||||
* CRUD: save/update/delete + updateValue
|
* 数据源: /supplier/supplierGoodPriceFormulaVariable/*
|
||||||
* 字段: id/var_key/var_name/var_unit/current_value/source_type/source_code/formula/sort/status/create_date
|
* 来源类型: 1手动输入 / 2自动采集(HTTP) / 3公式计算 / 4脚本获取(JS, Nashorn)
|
||||||
|
* 能力:
|
||||||
|
* - 新增/编辑/删除 (编辑走 save 全字段更新)
|
||||||
|
* - 行内快捷改值: 点击"当前值"直接输入新值 → updateValue → 引用商品公式自动重算
|
||||||
|
* - 立即刷新: 采集/脚本来源点击立即执行 → refresh → 值更新 → 引用商品重算 (不需改商品公式)
|
||||||
|
* - 自动刷新: refresh_interval_minutes 分钟间隔后台定时执行
|
||||||
*/
|
*/
|
||||||
import { ref, onMounted, reactive } from 'vue'
|
import { ref, onMounted, reactive } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
@ -21,6 +26,10 @@ interface FormulaVar {
|
|||||||
source_type: number
|
source_type: number
|
||||||
source_code: string
|
source_code: string
|
||||||
formula: string
|
formula: string
|
||||||
|
refresh_script: string
|
||||||
|
refresh_interval_minutes: number | null
|
||||||
|
last_refresh_time: any
|
||||||
|
last_refresh_msg: string | null
|
||||||
sort: number
|
sort: number
|
||||||
status: number
|
status: number
|
||||||
create_date: any
|
create_date: any
|
||||||
@ -30,12 +39,22 @@ interface FormulaVar {
|
|||||||
const SOURCE_MAP: Record<number, { label: string; type: string }> = {
|
const SOURCE_MAP: Record<number, { label: string; type: string }> = {
|
||||||
1: { label: '手动输入', type: 'info' },
|
1: { label: '手动输入', type: 'info' },
|
||||||
2: { label: '自动采集', type: 'success' },
|
2: { label: '自动采集', type: 'success' },
|
||||||
3: { label: '公式计算', type: 'warning' }
|
3: { label: '公式计算', type: 'warning' },
|
||||||
|
4: { label: '脚本获取', type: 'danger' }
|
||||||
}
|
}
|
||||||
const STATUS_MAP: Record<number, { label: string; type: string }> = {
|
const STATUS_MAP: Record<number, { label: string; type: string }> = {
|
||||||
0: { label: '禁用', type: 'danger' },
|
0: { label: '禁用', type: 'danger' },
|
||||||
1: { label: '启用', type: 'success' }
|
1: { label: '启用', type: 'success' }
|
||||||
}
|
}
|
||||||
|
// 自动刷新间隔选项 (分钟)
|
||||||
|
const INTERVAL_OPTIONS = [
|
||||||
|
{ value: 0, label: '不自动刷新' },
|
||||||
|
{ value: 10, label: '每 10 分钟' },
|
||||||
|
{ value: 30, label: '每 30 分钟' },
|
||||||
|
{ value: 60, label: '每小时' },
|
||||||
|
{ value: 240, label: '每 4 小时' },
|
||||||
|
{ value: 1440, label: '每天' }
|
||||||
|
]
|
||||||
|
|
||||||
const list = ref<FormulaVar[]>([])
|
const list = ref<FormulaVar[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
@ -45,19 +64,19 @@ const filters = reactive({ var_name: '' })
|
|||||||
|
|
||||||
const editVisible = ref(false)
|
const editVisible = ref(false)
|
||||||
const editMode = ref<'add' | 'edit'>('add')
|
const editMode = ref<'add' | 'edit'>('add')
|
||||||
const editForm = reactive<FormulaVar>({ id: '', var_key: '', var_name: '', var_unit: '', current_value: 0, source_type: 1, source_code: '', formula: '', sort: 0, status: 1, create_date: null, raw: {} })
|
const editForm = reactive<FormulaVar>({ id: '', var_key: '', var_name: '', var_unit: '', current_value: 0, source_type: 1, source_code: '', formula: '', refresh_script: '', refresh_interval_minutes: 0, last_refresh_time: null, last_refresh_msg: null, sort: 0, status: 1, create_date: null, raw: {} })
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ prop: 'id', label: 'ID', width: 60 },
|
{ prop: 'id', label: 'ID', width: 60 },
|
||||||
{ prop: 'var_name', label: '变量名称', minWidth: 120 },
|
{ prop: 'var_name', label: '变量名称', minWidth: 110 },
|
||||||
{ prop: 'var_key', label: '变量标识', width: 120 },
|
{ prop: 'var_unit', label: '单位', width: 70 },
|
||||||
{ prop: 'var_unit', label: '单位', width: 80 },
|
{ prop: 'current_value', label: '当前值', width: 110, slot: 'value' },
|
||||||
{ prop: 'current_value', label: '当前值', width: 120, formatter: (r: FormulaVar) => r.current_value?.toLocaleString() || '0' },
|
|
||||||
{ prop: 'source_type', label: '数据来源', width: 100, slot: 'source' },
|
{ prop: 'source_type', label: '数据来源', width: 100, slot: 'source' },
|
||||||
{ prop: 'formula', label: '计算公式', minWidth: 150, formatter: (r: FormulaVar) => r.formula || '--' },
|
{ prop: 'refresh_interval_minutes', label: '自动刷新', width: 100, slot: 'interval' },
|
||||||
{ prop: 'sort', label: '排序', width: 60 },
|
{ prop: 'last_refresh_time', label: '最近刷新', width: 170, slot: 'lastRefresh' },
|
||||||
{ prop: 'status', label: '状态', width: 80, slot: 'status' },
|
{ prop: 'status', label: '状态', width: 80, slot: 'status' },
|
||||||
{ prop: 'create_date', label: '创建时间', width: 160, formatter: (r: FormulaVar) => fmtDate(r.create_date) }
|
{ prop: 'create_date', label: '创建时间', width: 160, formatter: (r: FormulaVar) => fmtDate(r.create_date) },
|
||||||
|
{ label: '操作', width: 180, align: 'center' as const, slot: 'action' }
|
||||||
]
|
]
|
||||||
|
|
||||||
function fmtDate(d: any): string {
|
function fmtDate(d: any): string {
|
||||||
@ -65,6 +84,10 @@ function fmtDate(d: any): string {
|
|||||||
if (typeof d === 'string') return d.slice(0, 19)
|
if (typeof d === 'string') return d.slice(0, 19)
|
||||||
try { return `${d.year}-${String(d.monthValue).padStart(2,'0')}-${String(d.dayOfMonth).padStart(2,'0')} ${String(d.hour).padStart(2,'0')}:${String(d.minute).padStart(2,'0')}` } catch { return '--' }
|
try { return `${d.year}-${String(d.monthValue).padStart(2,'0')}-${String(d.dayOfMonth).padStart(2,'0')} ${String(d.hour).padStart(2,'0')}:${String(d.minute).padStart(2,'0')}` } catch { return '--' }
|
||||||
}
|
}
|
||||||
|
function intervalName(min: any): string {
|
||||||
|
const v = Number(min || 0)
|
||||||
|
return INTERVAL_OPTIONS.find(o => o.value === v)?.label || (v > 0 ? `每 ${v} 分钟` : '不自动')
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@ -81,22 +104,46 @@ function onLimitChange(l: number) { query.limit = l; query.page = 1; load() }
|
|||||||
|
|
||||||
function onAdd() {
|
function onAdd() {
|
||||||
editMode.value = 'add'
|
editMode.value = 'add'
|
||||||
Object.assign(editForm, { id: '', var_key: '', var_name: '', var_unit: '', current_value: 0, source_type: 1, source_code: '', formula: '', sort: 0, status: 1, create_date: null, raw: {} })
|
Object.assign(editForm, { id: '', var_key: '', var_name: '', var_unit: '', current_value: 0, source_type: 1, source_code: '', formula: '', refresh_script: '', refresh_interval_minutes: 0, last_refresh_time: null, last_refresh_msg: null, sort: 0, status: 1, create_date: null, raw: {} })
|
||||||
editVisible.value = true
|
editVisible.value = true
|
||||||
}
|
}
|
||||||
function onEdit(row: FormulaVar) {
|
function onEdit(row: FormulaVar) {
|
||||||
editMode.value = 'edit'
|
editMode.value = 'edit'
|
||||||
Object.assign(editForm, JSON.parse(JSON.stringify(row)))
|
Object.assign(editForm, JSON.parse(JSON.stringify(row)), { refresh_interval_minutes: row.refresh_interval_minutes || 0 })
|
||||||
editVisible.value = true
|
editVisible.value = true
|
||||||
}
|
}
|
||||||
function onSubmitEdit() {
|
function onSubmitEdit() {
|
||||||
if (!editForm.var_name.trim() || !editForm.var_key.trim()) { ElMessage.warning('请填写变量名称和标识'); return }
|
if (!editForm.var_name.trim()) { ElMessage.warning('请填写变量名称'); return }
|
||||||
const ep = editMode.value === 'add' ? '/supplier/supplierGoodPriceFormulaVariable/save' : '/supplier/supplierGoodPriceFormulaVariable/updateValue'
|
// 变量标识未填时自动取变量名称 (系统公式按变量名称引用, 标识仅作唯一性用途)
|
||||||
httpPost(ep, { ...editForm }).then(() => {
|
const payload = { ...editForm, var_key: editForm.var_key.trim() || editForm.var_name.trim() }
|
||||||
ElMessage.success(editMode.value === 'add' ? '新增成功' : '修改成功')
|
// 新增和编辑都走 save (全字段保存; 编辑不再走 updateValue 以免丢失名称/单位/来源等修改)
|
||||||
|
httpPost('/supplier/supplierGoodPriceFormulaVariable/save', payload).then(() => {
|
||||||
|
ElMessage.success(editMode.value === 'add' ? '新增成功' : '保存成功')
|
||||||
editVisible.value = false; load()
|
editVisible.value = false; load()
|
||||||
}).catch((e: any) => ElMessage.error(e?.msg || '操作失败'))
|
}).catch((e: any) => ElMessage.error(e?.msg || '操作失败'))
|
||||||
}
|
}
|
||||||
|
// 行内快捷改值 (只更新当前值, 触发引用商品公式重算)
|
||||||
|
function onQuickEditValue(row: FormulaVar) {
|
||||||
|
ElMessageBox.prompt(`请输入「${row.var_name}」的新当前值`, '修改当前值', {
|
||||||
|
inputValue: String(row.current_value ?? 0),
|
||||||
|
inputPattern: /^-?\d+(\.\d+)?$/,
|
||||||
|
inputErrorMessage: '请输入数字'
|
||||||
|
}).then(({ value }) => {
|
||||||
|
return httpPost('/supplier/supplierGoodPriceFormulaVariable/updateValue', { id: row.id, current_value: Number(value) })
|
||||||
|
}).then(() => {
|
||||||
|
ElMessage.success('已更新,引用该变量的商品价格已自动重算')
|
||||||
|
load()
|
||||||
|
}).catch((e: any) => {
|
||||||
|
if (e !== 'cancel' && e !== 'close') ElMessage.error(e?.msg || '更新失败')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 立即执行采集/脚本刷新
|
||||||
|
function onRefreshRow(row: FormulaVar) {
|
||||||
|
httpPost('/supplier/supplierGoodPriceFormulaVariable/refresh', { id: row.id }).then((r: any) => {
|
||||||
|
ElMessage.success(`刷新成功${r?.value != null ? ',新值 ' + r.value : ''},引用该变量的商品价格已自动重算`)
|
||||||
|
load()
|
||||||
|
}).catch((e: any) => ElMessage.error(e?.msg || '刷新失败'))
|
||||||
|
}
|
||||||
function onDelete(row: FormulaVar) {
|
function onDelete(row: FormulaVar) {
|
||||||
ElMessageBox.confirm(`确认删除「${row.var_name}」?`, '提示', { type: 'warning' }).then(async () => {
|
ElMessageBox.confirm(`确认删除「${row.var_name}」?`, '提示', { type: 'warning' }).then(async () => {
|
||||||
try { await httpPost('/supplier/supplierGoodPriceFormulaVariable/delete', { id: row.id }); ElMessage.success('已删除'); load() }
|
try { await httpPost('/supplier/supplierGoodPriceFormulaVariable/delete', { id: row.id }); ElMessage.success('已删除'); load() }
|
||||||
@ -118,9 +165,25 @@ onMounted(load)
|
|||||||
<DataTable :columns="columns" :data="list" :borderless="true" :loading="loading" :total="total"
|
<DataTable :columns="columns" :data="list" :borderless="true" :loading="loading" :total="total"
|
||||||
:page="query.page" :limit="query.limit" :show-pagination="true" :page-sizes="[10, 20, 50, 100]" row-key="id"
|
:page="query.page" :limit="query.limit" :show-pagination="true" :page-sizes="[10, 20, 50, 100]" row-key="id"
|
||||||
empty-text="暂无数据" @page-change="onPageChange" @limit-change="onLimitChange">
|
empty-text="暂无数据" @page-change="onPageChange" @limit-change="onLimitChange">
|
||||||
|
<template #value="{ row }">
|
||||||
|
<el-link type="primary" @click="onQuickEditValue(row)" class="rr-value-link">
|
||||||
|
{{ Number(row.current_value ?? 0).toLocaleString() }}
|
||||||
|
</el-link>
|
||||||
|
<div class="rr-value-tip">点击改值</div>
|
||||||
|
</template>
|
||||||
<template #source="{ row }">
|
<template #source="{ row }">
|
||||||
<el-tag :type="(SOURCE_MAP[row.source_type]?.type as any) || 'info'">{{ SOURCE_MAP[row.source_type]?.label || row.source_type }}</el-tag>
|
<el-tag :type="(SOURCE_MAP[row.source_type]?.type as any) || 'info'">{{ SOURCE_MAP[row.source_type]?.label || row.source_type }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
|
<template #interval="{ row }">
|
||||||
|
<span class="rr-muted">{{ intervalName(row.refresh_interval_minutes) }}</span>
|
||||||
|
</template>
|
||||||
|
<template #lastRefresh="{ row }">
|
||||||
|
<template v-if="row.last_refresh_time">
|
||||||
|
<div class="rr-refresh-time">{{ fmtDate(row.last_refresh_time) }}</div>
|
||||||
|
<div v-if="row.last_refresh_msg" class="rr-refresh-msg" :class="{ 'is-err': String(row.last_refresh_msg).startsWith('失败') }">{{ row.last_refresh_msg }}</div>
|
||||||
|
</template>
|
||||||
|
<span v-else class="rr-muted">--</span>
|
||||||
|
</template>
|
||||||
<template #status="{ row }">
|
<template #status="{ row }">
|
||||||
<el-tag :type="(STATUS_MAP[row.status]?.type as any) || 'info'">{{ STATUS_MAP[row.status]?.label || row.status }}</el-tag>
|
<el-tag :type="(STATUS_MAP[row.status]?.type as any) || 'info'">{{ STATUS_MAP[row.status]?.label || row.status }}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
@ -128,16 +191,21 @@ onMounted(load)
|
|||||||
<span class="rr-actions">
|
<span class="rr-actions">
|
||||||
<el-link type="primary" @click="onEdit(row)">编辑</el-link>
|
<el-link type="primary" @click="onEdit(row)">编辑</el-link>
|
||||||
<span class="rr-actions__sep">|</span>
|
<span class="rr-actions__sep">|</span>
|
||||||
|
<el-link v-if="[2, 4].includes(row.source_type)" type="success" @click="onRefreshRow(row)">立即刷新</el-link>
|
||||||
|
<span v-if="[2, 4].includes(row.source_type)" class="rr-actions__sep">|</span>
|
||||||
<el-link type="danger" @click="onDelete(row)">删除</el-link>
|
<el-link type="danger" @click="onDelete(row)">删除</el-link>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-dialog v-model="editVisible" :title="editMode === 'add' ? '新增变量' : '编辑变量'" width="500px">
|
<el-dialog v-model="editVisible" :title="editMode === 'add' ? '新增变量' : '编辑变量'" width="560px">
|
||||||
<el-form label-width="100px">
|
<el-form label-width="100px">
|
||||||
<el-form-item label="变量名称" required><el-input v-model="editForm.var_name" placeholder="如: 铜价" /></el-form-item>
|
<el-form-item label="变量名称" required><el-input v-model="editForm.var_name" placeholder="如: 铜价" /></el-form-item>
|
||||||
<el-form-item label="变量标识" required><el-input v-model="editForm.var_key" placeholder="如: copper_price" /></el-form-item>
|
<el-form-item label="变量标识">
|
||||||
|
<el-input v-model="editForm.var_key" placeholder="留空自动生成(默认与变量名称相同)" />
|
||||||
|
<div class="rr-form-tip">系统公式按变量名称引用,标识留空时自动取变量名称,无需手动填写</div>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="单位"><el-input v-model="editForm.var_unit" placeholder="如: 元/吨" /></el-form-item>
|
<el-form-item label="单位"><el-input v-model="editForm.var_unit" placeholder="如: 元/吨" /></el-form-item>
|
||||||
<el-form-item label="当前值"><el-input-number v-model="editForm.current_value" :precision="2" :step="100" /></el-form-item>
|
<el-form-item label="当前值"><el-input-number v-model="editForm.current_value" :precision="2" :step="100" /></el-form-item>
|
||||||
<el-form-item label="数据来源">
|
<el-form-item label="数据来源">
|
||||||
@ -145,10 +213,25 @@ onMounted(load)
|
|||||||
<el-radio :value="1">手动输入</el-radio>
|
<el-radio :value="1">手动输入</el-radio>
|
||||||
<el-radio :value="2">自动采集</el-radio>
|
<el-radio :value="2">自动采集</el-radio>
|
||||||
<el-radio :value="3">公式计算</el-radio>
|
<el-radio :value="3">公式计算</el-radio>
|
||||||
|
<el-radio :value="4">脚本获取</el-radio>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
|
<div class="rr-form-tip">自动采集=请求接口获取值;脚本获取=运行 JS 脚本计算值;两者都可自动刷新并联动重算引用商品价格</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="采集地址" v-if="editForm.source_type === 2">
|
||||||
|
<el-input v-model="editForm.source_code" placeholder='https://xxx/api/value (返回 {"value": 12.5} 或纯数字)' />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="脚本内容" v-if="editForm.source_type === 4">
|
||||||
|
<el-input v-model="editForm.refresh_script" type="textarea" :rows="5"
|
||||||
|
placeholder="JS 脚本,例:\nvar copper = Number(ctx.getValue('铜价'));\nctx.setResult(copper * 1.05); // 或直接返回数字" />
|
||||||
|
<div class="rr-form-tip">ctx.getValue("变量名") 读取其它变量当前值;ctx.setResult(数值) 或直接 return 数字</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="计算公式" v-if="editForm.source_type === 3"><el-input v-model="editForm.formula" placeholder="如: 铜价 * 1.05" /></el-form-item>
|
||||||
|
<el-form-item label="自动刷新" v-if="[2, 4].includes(editForm.source_type)">
|
||||||
|
<el-select v-model="editForm.refresh_interval_minutes" style="width: 200px">
|
||||||
|
<el-option v-for="o in INTERVAL_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
|
||||||
|
</el-select>
|
||||||
|
<div class="rr-form-tip">到时间自动执行采集/脚本更新当前值,并自动重算引用该变量的商品公式价格</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="采集编码" v-if="editForm.source_type === 2"><el-input v-model="editForm.source_code" placeholder="如: copper" /></el-form-item>
|
|
||||||
<el-form-item label="计算公式" v-if="editForm.source_type === 3"><el-input v-model="editForm.formula" placeholder="如: copper_price * 1.05" /></el-form-item>
|
|
||||||
<el-form-item label="排序"><el-input-number v-model="editForm.sort" :min="0" /></el-form-item>
|
<el-form-item label="排序"><el-input-number v-model="editForm.sort" :min="0" /></el-form-item>
|
||||||
<el-form-item label="状态">
|
<el-form-item label="状态">
|
||||||
<el-radio-group v-model="editForm.status">
|
<el-radio-group v-model="editForm.status">
|
||||||
@ -166,6 +249,14 @@ onMounted(load)
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.rr-page { max-width: 1400px; }
|
.rr-page { max-width: 1500px; }
|
||||||
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
|
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
|
||||||
|
.rr-form-tip { font-size: 12px; color: var(--ink-3); line-height: 1.5; margin-top: 4px; }
|
||||||
|
.rr-value-link { font-weight: 600; }
|
||||||
|
.rr-value-tip { font-size: 11px; color: var(--ink-3); line-height: 1; }
|
||||||
|
.rr-muted { color: var(--ink-3); font-size: 12.5px; }
|
||||||
|
.rr-refresh-time { font-size: 12.5px; color: var(--ink-2); }
|
||||||
|
.rr-refresh-msg { font-size: 12px; color: var(--st-ok-fg, #16a34a); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.rr-refresh-msg.is-err { color: var(--st-lv4-fg, #dc2626); }
|
||||||
|
.rr-actions__sep { color: var(--border); margin: 0 4px; }
|
||||||
</style>
|
</style>
|
||||||
@ -36,14 +36,14 @@ interface ServiceGood {
|
|||||||
raw: any
|
raw: any
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHECK_STATUS_MAP: Record<number, { label: string; type: string }> = {
|
const CHECK_STATUS_MAP: Record<number, { label: string; color: string; bg: string }> = {
|
||||||
1: { label: '待审核', type: 'warning' },
|
1: { label: '待审核', color: 'var(--st-warn-fg)', bg: 'var(--st-warn-bg)' },
|
||||||
2: { label: '已通过', type: 'success' },
|
2: { label: '已通过', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
|
||||||
3: { label: '已驳回', type: 'danger' }
|
3: { label: '已驳回', color: 'var(--st-err-fg)', bg: 'var(--st-err-bg)' }
|
||||||
}
|
}
|
||||||
const ONSHELF_STATUS_MAP: Record<number, { label: string; type: string }> = {
|
const ONSHELF_STATUS_MAP: Record<number, { label: string; color: string; bg: string }> = {
|
||||||
0: { label: '已下架', type: 'info' },
|
0: { label: '已下架', color: 'var(--st-lv4-fg)', bg: 'var(--st-lv4-bg)' },
|
||||||
1: { label: '已上架', type: 'success' }
|
1: { label: '已上架', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' }
|
||||||
}
|
}
|
||||||
const STATUS_TABS = [
|
const STATUS_TABS = [
|
||||||
{ value: '', label: '全部' },
|
{ value: '', label: '全部' },
|
||||||
@ -300,7 +300,7 @@ onMounted(() => Promise.all([load(), loadStats(), loadCounts()]))
|
|||||||
>
|
>
|
||||||
<template #title="{ row }">
|
<template #title="{ row }">
|
||||||
<div class="rr-good-cell">
|
<div class="rr-good-cell">
|
||||||
<div v-if="row.icon" class="rr-good-icon"><el-image :src="row.icon" :preview-src-list="[row.icon]" fit="cover" style="width: 40px; height: 40px; border-radius: 4px" /></div>
|
<div v-if="row.icon" class="rr-good-icon"><el-image :src="row.icon" :preview-src-list="[row.icon]" fit="cover" style="width: 40px; height: 40px; border-radius: 4px" preview-teleported /></div>
|
||||||
<div v-else class="rr-good-icon-placeholder">{{ (row.title || '?').charAt(0) }}</div>
|
<div v-else class="rr-good-icon-placeholder">{{ (row.title || '?').charAt(0) }}</div>
|
||||||
<span>{{ row.title }}</span>
|
<span>{{ row.title }}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -309,10 +309,10 @@ onMounted(() => Promise.all([load(), loadStats(), loadCounts()]))
|
|||||||
<span class="rr-price">¥ {{ Number(row.price || 0).toLocaleString() }} / {{ row.unit }}</span>
|
<span class="rr-price">¥ {{ Number(row.price || 0).toLocaleString() }} / {{ row.unit }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template #onShelf="{ row }">
|
<template #onShelf="{ row }">
|
||||||
<el-tag :type="(ONSHELF_STATUS_MAP[row.onShelf]?.type as any)" size="small" effect="light">{{ ONSHELF_STATUS_MAP[row.onShelf]?.label }}</el-tag>
|
<span class="st-pill" :style="{ color: ONSHELF_STATUS_MAP[row.onShelf]?.color, background: ONSHELF_STATUS_MAP[row.onShelf]?.bg }"><span class="st-dot"></span>{{ ONSHELF_STATUS_MAP[row.onShelf]?.label }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template #checkStatus="{ row }">
|
<template #checkStatus="{ row }">
|
||||||
<el-tag :type="(CHECK_STATUS_MAP[row.checkStatus]?.type as any)" size="small" effect="light">{{ CHECK_STATUS_MAP[row.checkStatus]?.label }}</el-tag>
|
<span class="st-pill" :style="{ color: CHECK_STATUS_MAP[row.checkStatus]?.color, background: CHECK_STATUS_MAP[row.checkStatus]?.bg }"><span class="st-dot"></span>{{ CHECK_STATUS_MAP[row.checkStatus]?.label }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template #action="{ row }">
|
<template #action="{ row }">
|
||||||
<TableActions
|
<TableActions
|
||||||
@ -328,7 +328,7 @@ onMounted(() => Promise.all([load(), loadStats(), loadCounts()]))
|
|||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
|
||||||
<el-dialog v-model="detailVisible" title="服务详情" width="700px">
|
<el-dialog v-model="detailVisible" title="服务详情" width="800px">
|
||||||
<el-descriptions v-if="detailData" :column="2" border>
|
<el-descriptions v-if="detailData" :column="2" border>
|
||||||
<el-descriptions-item label="服务ID">{{ detailData.serviceGoodId }}</el-descriptions-item>
|
<el-descriptions-item label="服务ID">{{ detailData.serviceGoodId }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="服务名称" :span="2">{{ detailData.title }}</el-descriptions-item>
|
<el-descriptions-item label="服务名称" :span="2">{{ detailData.title }}</el-descriptions-item>
|
||||||
@ -337,10 +337,10 @@ onMounted(() => Promise.all([load(), loadStats(), loadCounts()]))
|
|||||||
<el-descriptions-item label="服务区域">{{ detailData.serviceArea }}</el-descriptions-item>
|
<el-descriptions-item label="服务区域">{{ detailData.serviceArea }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="价格">¥ {{ detailData.price.toLocaleString() }} / {{ detailData.unit }}</el-descriptions-item>
|
<el-descriptions-item label="价格">¥ {{ detailData.price.toLocaleString() }} / {{ detailData.unit }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="状态">
|
<el-descriptions-item label="状态">
|
||||||
<el-tag :type="(CHECK_STATUS_MAP[detailData.checkStatus]?.type as any)" size="small">{{ CHECK_STATUS_MAP[detailData.checkStatus]?.label }}</el-tag>
|
<span class="st-pill" :style="{ color: CHECK_STATUS_MAP[detailData.checkStatus]?.color, background: CHECK_STATUS_MAP[detailData.checkStatus]?.bg }"><span class="st-dot"></span>{{ CHECK_STATUS_MAP[detailData.checkStatus]?.label }}</span>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="铺货">
|
<el-descriptions-item label="铺货">
|
||||||
<el-tag :type="(ONSHELF_STATUS_MAP[detailData.onShelf]?.type as any)" size="small">{{ ONSHELF_STATUS_MAP[detailData.onShelf]?.label }}</el-tag>
|
<span class="st-pill" :style="{ color: ONSHELF_STATUS_MAP[detailData.onShelf]?.color, background: ONSHELF_STATUS_MAP[detailData.onShelf]?.bg }"><span class="st-dot"></span>{{ ONSHELF_STATUS_MAP[detailData.onShelf]?.label }}</span>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="审核意见" :span="2">{{ detailData.checkOpinion || '--' }}</el-descriptions-item>
|
<el-descriptions-item label="审核意见" :span="2">{{ detailData.checkOpinion || '--' }}</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
|||||||
366
src/modules/sys/AiManageListView.vue
Normal file
366
src/modules/sys/AiManageListView.vue
Normal file
@ -0,0 +1,366 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* AI 能力管理 — admin(系统管理组)
|
||||||
|
*
|
||||||
|
* 模型配置: OpenAI兼容渠道 CRUD + 全局启用(互斥) + 连通性测试 (api_key 脱敏回显/留空不改)
|
||||||
|
* 功能授权: AI 功能清单(总开关/绑定模型) × 企业白名单(搜索添加/移除)
|
||||||
|
* 数据源: /other/aimodel/* (other-server, tb_ai_model_config / tb_ai_feature / tb_ai_feature_auth)
|
||||||
|
*/
|
||||||
|
import { ref, onMounted, reactive } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Plus, Search, Connection } from '@element-plus/icons-vue'
|
||||||
|
import { httpGet, httpPost } from '@/utils/request'
|
||||||
|
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||||
|
import ListTabs from '@/components/ListTabs/ListTabs.vue'
|
||||||
|
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||||
|
import TableActions from '@/components/TableActions/TableActions.vue'
|
||||||
|
|
||||||
|
const activeTab = ref('model')
|
||||||
|
const tabItems = [
|
||||||
|
{ key: 'model', label: '模型配置' },
|
||||||
|
{ key: 'feature', label: '功能授权' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// ===== 模型配置 =====
|
||||||
|
const models = ref<any[]>([])
|
||||||
|
const modelTotal = ref(0)
|
||||||
|
const modelLoading = ref(false)
|
||||||
|
const modelPage = ref(1)
|
||||||
|
const modelLimit = ref(20)
|
||||||
|
|
||||||
|
const modelColumns: any = [
|
||||||
|
{ prop: 'name', label: '配置名称', minWidth: 130, slot: 'name' },
|
||||||
|
{ prop: 'base_url', label: '接口地址', minWidth: 220, slot: 'url' },
|
||||||
|
{ prop: 'model', label: '模型', width: 150 },
|
||||||
|
{ prop: 'api_key_masked', label: 'API Key', width: 110 },
|
||||||
|
{ prop: 'max_chars', label: '最大字符', width: 85, align: 'center' },
|
||||||
|
{ prop: 'timeout_seconds', label: '超时(秒)', width: 80, align: 'center' },
|
||||||
|
{ prop: 'enabled', label: '状态', width: 90, align: 'center', slot: 'enabled' },
|
||||||
|
{ prop: 'remark', label: '备注', minWidth: 120, slot: 'remark' },
|
||||||
|
{ label: '操作', width: 230, align: 'center', slot: 'action' }
|
||||||
|
]
|
||||||
|
|
||||||
|
async function loadModels() {
|
||||||
|
modelLoading.value = true
|
||||||
|
try {
|
||||||
|
const r: any = await httpGet('/other/aimodel/modelPage', { page: modelPage.value, limit: modelLimit.value })
|
||||||
|
models.value = r?.list || []
|
||||||
|
modelTotal.value = Number(r?.total || 0)
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || '加载失败') } finally { modelLoading.value = false }
|
||||||
|
}
|
||||||
|
function onModelPage(p: number) { modelPage.value = p; loadModels() }
|
||||||
|
|
||||||
|
const testing = ref(-1)
|
||||||
|
async function onTest(row: any) {
|
||||||
|
testing.value = row.id
|
||||||
|
try {
|
||||||
|
const r: any = await httpPost('/other/aimodel/test', { id: row.id })
|
||||||
|
if (r?.code === 0 || (r as any)?.reply != null) {
|
||||||
|
ElMessage.success(`连通正常(${Math.round(r.costMs / 100) / 10}s): ${r.reply || 'OK'}`)
|
||||||
|
} else {
|
||||||
|
ElMessage.error(r?.msg || '连通失败')
|
||||||
|
}
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '连通失败') } finally { testing.value = -1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onEnable(row: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认将【${row.name}】设为全局启用的AI模型? 所有未指定模型的功能将使用它`, '启用确认', { type: 'warning' })
|
||||||
|
} catch { return }
|
||||||
|
try {
|
||||||
|
await httpPost('/other/aimodel/modelEnable', { id: row.id })
|
||||||
|
ElMessage.success('已设为全局启用')
|
||||||
|
loadModels()
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || '操作失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDeleteModel(row: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认删除模型配置【${row.name}】?`, '删除确认', { type: 'warning' })
|
||||||
|
} catch { return }
|
||||||
|
try {
|
||||||
|
await httpPost('/other/aimodel/modelDelete', { id: row.id })
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
loadModels()
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || '删除失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模型编辑弹窗
|
||||||
|
const modelDialog = ref(false)
|
||||||
|
const modelSaving = ref(false)
|
||||||
|
const modelForm = reactive({ id: null as any, name: '', baseUrl: '', apiKey: '', model: '', maxChars: 60000, timeoutSeconds: 180, remark: '' })
|
||||||
|
function openModelDialog(row?: any) {
|
||||||
|
if (row) {
|
||||||
|
Object.assign(modelForm, { id: row.id, name: row.name, baseUrl: row.base_url, apiKey: '', model: row.model, maxChars: Number(row.max_chars), timeoutSeconds: Number(row.timeout_seconds), remark: row.remark || '' })
|
||||||
|
} else {
|
||||||
|
Object.assign(modelForm, { id: null, name: '', baseUrl: '', apiKey: '', model: '', maxChars: 60000, timeoutSeconds: 180, remark: '' })
|
||||||
|
}
|
||||||
|
modelDialog.value = true
|
||||||
|
}
|
||||||
|
async function saveModel() {
|
||||||
|
if (!modelForm.name.trim() || !modelForm.baseUrl.trim() || !modelForm.model.trim()) { ElMessage.warning('名称/接口地址/模型名不能为空'); return }
|
||||||
|
if (!modelForm.id && !modelForm.apiKey.trim()) { ElMessage.warning('新增时 API Key 不能为空'); return }
|
||||||
|
modelSaving.value = true
|
||||||
|
try {
|
||||||
|
await httpPost('/other/aimodel/modelSave', { ...modelForm })
|
||||||
|
ElMessage.success('已保存')
|
||||||
|
modelDialog.value = false
|
||||||
|
loadModels()
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '保存失败') } finally { modelSaving.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 功能授权 =====
|
||||||
|
const features = ref<any[]>([])
|
||||||
|
const featureLoading = ref(false)
|
||||||
|
const expandedFeature = ref<string>('')
|
||||||
|
|
||||||
|
const featureColumns: any = [
|
||||||
|
{ prop: 'code', label: '功能编码', width: 160, slot: 'code' },
|
||||||
|
{ prop: 'name', label: '功能名称', minWidth: 140 },
|
||||||
|
{ prop: 'model_name', label: '指定模型', width: 130, slot: 'fmodel' },
|
||||||
|
{ prop: 'enabled', label: '总开关', width: 80, align: 'center', slot: 'fenabled' },
|
||||||
|
{ prop: 'auth_count', label: '授权企业', width: 85, align: 'center', slot: 'authcount' },
|
||||||
|
{ prop: 'description', label: '说明', minWidth: 200, slot: 'fdesc' },
|
||||||
|
{ label: '操作', width: 170, align: 'center', slot: 'faction' }
|
||||||
|
]
|
||||||
|
|
||||||
|
async function loadFeatures() {
|
||||||
|
featureLoading.value = true
|
||||||
|
try {
|
||||||
|
const r: any = await httpGet('/other/aimodel/featurePage')
|
||||||
|
features.value = r || []
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || '加载失败') } finally { featureLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 功能编辑
|
||||||
|
const featureDialog = ref(false)
|
||||||
|
const featureForm = reactive({ id: null as any, code: '', name: '', modelConfigId: null as any, enabled: 1, description: '' })
|
||||||
|
function openFeatureDialog(row?: any) {
|
||||||
|
if (row) {
|
||||||
|
Object.assign(featureForm, { id: row.id, code: row.code, name: row.name, modelConfigId: row.model_config_id, enabled: Number(row.enabled), description: row.description || '' })
|
||||||
|
} else {
|
||||||
|
Object.assign(featureForm, { id: null, code: '', name: '', modelConfigId: null, enabled: 1, description: '' })
|
||||||
|
}
|
||||||
|
featureDialog.value = true
|
||||||
|
}
|
||||||
|
async function saveFeature() {
|
||||||
|
if (!featureForm.code.trim() || !featureForm.name.trim()) { ElMessage.warning('编码/名称不能为空'); return }
|
||||||
|
try {
|
||||||
|
await httpPost('/other/aimodel/featureSave', { ...featureForm })
|
||||||
|
ElMessage.success('已保存')
|
||||||
|
featureDialog.value = false
|
||||||
|
loadFeatures()
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || '保存失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 授权管理
|
||||||
|
const authFeature = ref<any>(null)
|
||||||
|
const authList = ref<any[]>([])
|
||||||
|
const authLoading = ref(false)
|
||||||
|
const addDialog = ref(false)
|
||||||
|
const addKeyword = ref('')
|
||||||
|
const addSearching = ref(false)
|
||||||
|
const addCandidates = ref<any[]>([])
|
||||||
|
const addSelected = ref<any>(null)
|
||||||
|
|
||||||
|
function openAuth(row: any) {
|
||||||
|
authFeature.value = row
|
||||||
|
addKeyword.value = ''
|
||||||
|
addCandidates.value = []
|
||||||
|
addSelected.value = null
|
||||||
|
loadAuth()
|
||||||
|
}
|
||||||
|
async function loadAuth() {
|
||||||
|
if (!authFeature.value) return
|
||||||
|
authLoading.value = true
|
||||||
|
try {
|
||||||
|
const r: any = await httpGet('/other/aimodel/authList', { featureCode: authFeature.value.code })
|
||||||
|
authList.value = r || []
|
||||||
|
} catch { authList.value = [] } finally { authLoading.value = false }
|
||||||
|
}
|
||||||
|
async function removeAuth(row: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认移除【${row.enterprise_name}】的「${authFeature.value.name}」授权?`, '移除确认', { type: 'warning' })
|
||||||
|
} catch { return }
|
||||||
|
try {
|
||||||
|
await httpPost('/other/aimodel/authRemove', { id: row.id })
|
||||||
|
ElMessage.success('已移除')
|
||||||
|
loadAuth(); loadFeatures()
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || '移除失败') }
|
||||||
|
}
|
||||||
|
function openAdd() { addDialog.value = true; addKeyword.value = ''; addCandidates.value = []; addSelected.value = null }
|
||||||
|
async function searchEnt() {
|
||||||
|
if (!addKeyword.value.trim()) { ElMessage.warning('请输入企业名称关键词'); return }
|
||||||
|
addSearching.value = true
|
||||||
|
try {
|
||||||
|
const r: any = await httpGet('/user/enterprise/page', { page: 1, limit: 20, status: 2, enterpriseName: addKeyword.value.trim() })
|
||||||
|
addCandidates.value = (r?.list || []).map((it: any) => ({ id: String(it.id), name: it.enterpriseName }))
|
||||||
|
} finally { addSearching.value = false }
|
||||||
|
}
|
||||||
|
async function confirmAdd() {
|
||||||
|
if (!addSelected.value) { ElMessage.warning('请先选择企业'); return }
|
||||||
|
try {
|
||||||
|
await httpPost('/other/aimodel/authAdd', { featureCode: authFeature.value.code, enterpriseId: addSelected.value.id, enterpriseName: addSelected.value.name })
|
||||||
|
ElMessage.success('已授权')
|
||||||
|
addDialog.value = false
|
||||||
|
loadAuth(); loadFeatures()
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || '授权失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { loadModels(); loadFeatures() })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rr-page">
|
||||||
|
<PageHeader title="AI 能力管理" sub="大模型渠道配置与 AI 功能的企业授权管理">
|
||||||
|
<el-button type="primary" :icon="Plus" @click="openModelDialog()">新增模型配置</el-button>
|
||||||
|
<el-button :icon="Plus" @click="openFeatureDialog()">注册AI功能</el-button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<ListTabs v-model="activeTab" :items="tabItems" @change="(v: string) => { activeTab = v }" />
|
||||||
|
|
||||||
|
<!-- 模型配置 -->
|
||||||
|
<template v-if="activeTab === 'model'">
|
||||||
|
<DataTable :data="models" :columns="modelColumns" :loading="modelLoading" :total="modelTotal" :page="modelPage" :limit="modelLimit" row-key="id" @page-change="onModelPage">
|
||||||
|
<template #name="{ row }">
|
||||||
|
<span style="font-weight:600">{{ row.name }}</span>
|
||||||
|
</template>
|
||||||
|
<template #url="{ row }"><span class="rr-mono">{{ row.base_url }}</span></template>
|
||||||
|
<template #enabled="{ row }">
|
||||||
|
<el-tag :type="Number(row.enabled) === 1 ? 'success' : 'info'" size="small">{{ Number(row.enabled) === 1 ? '全局启用' : '停用' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
<template #remark="{ row }"><span class="rr-muted">{{ row.remark || '—' }}</span></template>
|
||||||
|
<template #action="{ row }">
|
||||||
|
<TableActions :row="row" :actions="[
|
||||||
|
{ label: '测试连通', type: 'primary', onClick: (r: any) => onTest(r) },
|
||||||
|
{ label: '编辑', type: 'primary', onClick: (r: any) => openModelDialog(r) },
|
||||||
|
...(Number(row.enabled) !== 1 ? [{ label: '设为启用', type: 'success' as const, onClick: (r: any) => onEnable(r) }] : []),
|
||||||
|
...(Number(row.enabled) !== 1 ? [{ label: '删除', type: 'danger' as const, onClick: (r: any) => onDeleteModel(r) }] : [])
|
||||||
|
]" />
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
<div v-if="!modelLoading && !models.length" class="empty-tip">暂无模型配置——点击右上角「新增模型配置」接入 OpenAI 兼容接口(智谱/DeepSeek/通义/自建中转均可)</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 功能授权 -->
|
||||||
|
<template v-else>
|
||||||
|
<DataTable :data="features" :columns="featureColumns" :loading="featureLoading" row-key="id">
|
||||||
|
<template #code="{ row }"><span class="rr-mono">{{ row.code }}</span></template>
|
||||||
|
<template #fmodel="{ row }">
|
||||||
|
<span v-if="row.model_name">{{ row.model_name }}</span>
|
||||||
|
<span v-else class="rr-muted">跟随全局</span>
|
||||||
|
</template>
|
||||||
|
<template #fenabled="{ row }">
|
||||||
|
<el-tag :type="Number(row.enabled) === 1 ? 'success' : 'danger'" size="small">{{ Number(row.enabled) === 1 ? '开启' : '停用' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
<template #authcount="{ row }">
|
||||||
|
<el-link type="primary" :underline="false" @click="openAuth(row)">{{ row.auth_count }} 家</el-link>
|
||||||
|
</template>
|
||||||
|
<template #fdesc="{ row }"><span class="rr-muted">{{ row.description || '—' }}</span></template>
|
||||||
|
<template #faction="{ row }">
|
||||||
|
<TableActions :row="row" :actions="[
|
||||||
|
{ label: '管理授权', type: 'primary', onClick: (r: any) => openAuth(r) },
|
||||||
|
{ label: '编辑', type: 'primary', onClick: (r: any) => openFeatureDialog(r) }
|
||||||
|
]" />
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<!-- 授权白名单 -->
|
||||||
|
<div v-if="authFeature" class="auth-block">
|
||||||
|
<div class="auth-title">
|
||||||
|
「{{ authFeature.name }}」授权企业({{ authList.length }} 家)
|
||||||
|
<el-button size="small" type="primary" :icon="Plus" @click="openAdd">添加企业</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="authList" v-loading="authLoading" size="small" border>
|
||||||
|
<el-table-column prop="enterprise_name" label="企业名称" min-width="240" />
|
||||||
|
<el-table-column prop="create_time" label="授权时间" width="170">
|
||||||
|
<template #default="{ row }"><span class="rr-mono">{{ String(row.create_time || '').replace('T', ' ').slice(0, 19) }}</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="90" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" type="danger" plain @click="removeAuth(row)">移除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 模型编辑弹窗 -->
|
||||||
|
<el-dialog v-model="modelDialog" :title="modelForm.id ? '编辑模型配置' : '新增模型配置'" width="560px" destroy-on-close>
|
||||||
|
<el-form label-width="100px">
|
||||||
|
<el-form-item label="配置名称 *"><el-input v-model="modelForm.name" placeholder="如: 生产-GLM-4" maxlength="64" /></el-form-item>
|
||||||
|
<el-form-item label="接口地址 *"><el-input v-model="modelForm.baseUrl" placeholder="OpenAI兼容, 如 https://api.xx.com/v1" /></el-form-item>
|
||||||
|
<el-form-item :label="modelForm.id ? 'API Key(留空不改)' : 'API Key *'"><el-input v-model="modelForm.apiKey" placeholder="sk-..." show-password /></el-form-item>
|
||||||
|
<el-form-item label="模型名 *"><el-input v-model="modelForm.model" placeholder="如 glm-4-long / deepseek-chat" /></el-form-item>
|
||||||
|
<div style="display:flex;gap:12px">
|
||||||
|
<el-form-item label="最大字符"><el-input-number v-model="modelForm.maxChars" :min="1000" :max="500000" :step="10000" style="width:160px" /></el-form-item>
|
||||||
|
<el-form-item label="超时(秒)"><el-input-number v-model="modelForm.timeoutSeconds" :min="30" :max="600" :step="30" style="width:130px" /></el-form-item>
|
||||||
|
</div>
|
||||||
|
<el-form-item label="备注"><el-input v-model="modelForm.remark" maxlength="255" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="modelDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="modelSaving" @click="saveModel">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 功能编辑弹窗 -->
|
||||||
|
<el-dialog v-model="featureDialog" :title="featureForm.id ? '编辑AI功能' : '注册AI功能'" width="520px" destroy-on-close>
|
||||||
|
<el-form label-width="100px">
|
||||||
|
<el-form-item label="功能编码 *"><el-input v-model="featureForm.code" :disabled="!!featureForm.id" placeholder="如 inquiry_parse" maxlength="64" /></el-form-item>
|
||||||
|
<el-form-item label="功能名称 *"><el-input v-model="featureForm.name" maxlength="64" /></el-form-item>
|
||||||
|
<el-form-item label="指定模型">
|
||||||
|
<el-select v-model="featureForm.modelConfigId" placeholder="不指定=跟随全局启用" clearable style="width:260px">
|
||||||
|
<el-option v-for="m in models" :key="m.id" :label="`${m.name}(${m.model})`" :value="m.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="总开关">
|
||||||
|
<el-switch v-model="featureForm.enabled" :active-value="1" :inactive-value="0" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="说明"><el-input v-model="featureForm.description" type="textarea" :rows="2" maxlength="500" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="featureDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="saveFeature">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 添加授权企业弹窗 -->
|
||||||
|
<el-dialog v-model="addDialog" :title="`添加授权企业 — ${authFeature?.name || ''}`" width="520px" destroy-on-close>
|
||||||
|
<div class="add-search">
|
||||||
|
<el-input v-model="addKeyword" placeholder="输入企业名称搜索(仅已认证企业)" clearable @keyup.enter="searchEnt">
|
||||||
|
<template #append><el-button :icon="Search" :loading="addSearching" @click="searchEnt">搜索</el-button></template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
<div class="add-list">
|
||||||
|
<div v-for="c in addCandidates" :key="c.id" :class="['add-item', { active: addSelected?.id === c.id }]" @click="addSelected = c">
|
||||||
|
<span class="add-name">{{ c.name }}</span>
|
||||||
|
<span class="add-check">✓</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!addCandidates.length" class="rr-muted" style="text-align:center;padding:24px 0">输入关键词搜索并选择企业</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="addDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" :disabled="!addSelected" @click="confirmAdd">确认授权</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.rr-page { max-width: 1500px; }
|
||||||
|
.rr-muted { color: var(--ink-3); }
|
||||||
|
.rr-mono { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--ink-2); }
|
||||||
|
.empty-tip { text-align: center; color: var(--ink-3); padding: 28px 0; font-size: 13px; }
|
||||||
|
.auth-block { border: 1px solid var(--border-1, #e5e7eb); border-radius: 10px; padding: 14px 16px; margin-top: 14px; background: var(--bg-1, #fafafa); }
|
||||||
|
.auth-title { display: flex; align-items: center; justify-content: space-between; font-weight: 600; margin-bottom: 10px; font-size: 13px; }
|
||||||
|
.add-search { margin-bottom: 12px; }
|
||||||
|
.add-list { max-height: 320px; overflow-y: auto; border: 1px solid var(--border-1, #e5e7eb); border-radius: 10px; }
|
||||||
|
.add-item { display: flex; align-items: center; justify-content: space-between; padding: 11px 14px; cursor: pointer; border-bottom: 1px solid var(--border-1, #e5e7eb); transition: background .12s; }
|
||||||
|
.add-item:last-child { border-bottom: none; }
|
||||||
|
.add-item:hover { background: var(--bg-2, #f8fafc); }
|
||||||
|
.add-item.active { background: var(--primary-soft, #eff6ff); box-shadow: inset 0 0 0 1px var(--primary); }
|
||||||
|
.add-name { font-size: 13.5px; font-weight: 600; }
|
||||||
|
.add-check { width: 18px; height: 18px; border-radius: 50%; background: var(--primary); color: #fff; font-size: 11px; text-align: center; line-height: 18px; display: none; }
|
||||||
|
.add-item.active .add-check { display: block; }
|
||||||
|
</style>
|
||||||
@ -226,7 +226,7 @@ onMounted(load)
|
|||||||
.rr-muted { color: var(--ink-3); font-size: 12px; }
|
.rr-muted { color: var(--ink-3); font-size: 12px; }
|
||||||
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||||
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
||||||
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
|
.rr-page-title { margin: 0; font-size: 20px; font-weight: 700; color: var(--el-text-color-primary); }
|
||||||
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||||
.rr-page-actions { display: flex; gap: 8px; }
|
.rr-page-actions { display: flex; gap: 8px; }
|
||||||
.rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; }
|
.rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; }
|
||||||
|
|||||||
@ -229,7 +229,7 @@ onMounted(load)
|
|||||||
.rr-muted { color: var(--ink-3); font-size: 12px; }
|
.rr-muted { color: var(--ink-3); font-size: 12px; }
|
||||||
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||||
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
||||||
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
|
.rr-page-title { margin: 0; font-size: 20px; font-weight: 700; color: var(--el-text-color-primary); }
|
||||||
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||||
.rr-page-actions { display: flex; gap: 8px; }
|
.rr-page-actions { display: flex; gap: 8px; }
|
||||||
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
|
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
|
||||||
|
|||||||
@ -213,7 +213,7 @@ onMounted(load)
|
|||||||
.rr-muted { color: var(--ink-3); font-size: 12px; }
|
.rr-muted { color: var(--ink-3); font-size: 12px; }
|
||||||
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||||
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
||||||
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
|
.rr-page-title { margin: 0; font-size: 20px; font-weight: 700; color: var(--el-text-color-primary); }
|
||||||
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||||
.rr-page-actions { display: flex; gap: 8px; }
|
.rr-page-actions { display: flex; gap: 8px; }
|
||||||
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
|
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
|
||||||
|
|||||||
@ -137,7 +137,7 @@ onMounted(load)
|
|||||||
.rr-muted { color: var(--ink-3); font-size: 12px; }
|
.rr-muted { color: var(--ink-3); font-size: 12px; }
|
||||||
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||||
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
||||||
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
|
.rr-page-title { margin: 0; font-size: 20px; font-weight: 700; color: var(--el-text-color-primary); }
|
||||||
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||||
.rr-page-actions { display: flex; gap: 8px; }
|
.rr-page-actions { display: flex; gap: 8px; }
|
||||||
.rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; }
|
.rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; }
|
||||||
|
|||||||
@ -220,7 +220,7 @@ onMounted(load)
|
|||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="editVisible"
|
v-model="editVisible"
|
||||||
:title="editMode === 'create' ? '新增菜单' : '编辑菜单'"
|
:title="editMode === 'create' ? '新增菜单' : '编辑菜单'"
|
||||||
width="600"
|
width="640px"
|
||||||
:style="{ borderRadius: '12px', overflow: 'hidden' }"
|
:style="{ borderRadius: '12px', overflow: 'hidden' }"
|
||||||
align-center
|
align-center
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
@ -275,7 +275,7 @@ onMounted(load)
|
|||||||
.rr-muted { color: var(--ink-3); font-size: 12px; }
|
.rr-muted { color: var(--ink-3); font-size: 12px; }
|
||||||
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||||
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
||||||
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
|
.rr-page-title { margin: 0; font-size: 20px; font-weight: 700; color: var(--el-text-color-primary); }
|
||||||
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||||
.rr-page-actions { display: flex; gap: 8px; }
|
.rr-page-actions { display: flex; gap: 8px; }
|
||||||
.rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; }
|
.rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; }
|
||||||
|
|||||||
@ -260,7 +260,7 @@ onMounted(load)
|
|||||||
.rr-muted { color: var(--ink-3); font-size: 12px; }
|
.rr-muted { color: var(--ink-3); font-size: 12px; }
|
||||||
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||||
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
||||||
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
|
.rr-page-title { margin: 0; font-size: 20px; font-weight: 700; color: var(--el-text-color-primary); }
|
||||||
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||||
.rr-page-actions { display: flex; gap: 8px; }
|
.rr-page-actions { display: flex; gap: 8px; }
|
||||||
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
|
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
|
||||||
|
|||||||
@ -221,7 +221,7 @@ onMounted(load)
|
|||||||
.rr-muted { color: var(--ink-3); font-size: 12px; }
|
.rr-muted { color: var(--ink-3); font-size: 12px; }
|
||||||
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||||
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
||||||
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
|
.rr-page-title { margin: 0; font-size: 20px; font-weight: 700; color: var(--el-text-color-primary); }
|
||||||
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||||
.rr-page-actions { display: flex; gap: 8px; }
|
.rr-page-actions { display: flex; gap: 8px; }
|
||||||
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
|
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
|
||||||
|
|||||||
@ -254,7 +254,7 @@ onMounted(load)
|
|||||||
.rr-muted { color: var(--ink-3); font-size: 12px; }
|
.rr-muted { color: var(--ink-3); font-size: 12px; }
|
||||||
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||||
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
||||||
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
|
.rr-page-title { margin: 0; font-size: 20px; font-weight: 700; color: var(--el-text-color-primary); }
|
||||||
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||||
.rr-page-actions { display: flex; gap: 8px; }
|
.rr-page-actions { display: flex; gap: 8px; }
|
||||||
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
|
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
|
||||||
|
|||||||
@ -189,7 +189,7 @@ onMounted(load)
|
|||||||
.rr-muted { color: var(--ink-3); font-size: 12px; }
|
.rr-muted { color: var(--ink-3); font-size: 12px; }
|
||||||
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||||
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
||||||
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
|
.rr-page-title { margin: 0; font-size: 20px; font-weight: 700; color: var(--el-text-color-primary); }
|
||||||
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||||
.rr-page-actions { display: flex; gap: 8px; }
|
.rr-page-actions { display: flex; gap: 8px; }
|
||||||
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
|
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
|
||||||
|
|||||||
@ -193,7 +193,7 @@ onMounted(load)
|
|||||||
.rr-muted { color: var(--ink-3); font-size: 12px; }
|
.rr-muted { color: var(--ink-3); font-size: 12px; }
|
||||||
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||||
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
||||||
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
|
.rr-page-title { margin: 0; font-size: 20px; font-weight: 700; color: var(--el-text-color-primary); }
|
||||||
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||||
.rr-page-actions { display: flex; gap: 8px; }
|
.rr-page-actions { display: flex; gap: 8px; }
|
||||||
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
|
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
|
||||||
|
|||||||
@ -1,30 +1,25 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* F006 用户管理 - 套老 vue user.vue 结构 + v1.8 设计规范
|
* F006 用户管理 - 2026-08-18 按企业列表 UI 标准升级
|
||||||
*
|
*
|
||||||
* 后端端点 (SysUserController):
|
* 后端端点 (SysUserController):
|
||||||
* GET /sys/user/page 分页列表
|
* GET /sys/user/page 分页列表 (支持 username/userType 过滤)
|
||||||
* GET /sys/user/{id} 详情
|
|
||||||
* POST /sys/user 新增
|
|
||||||
* PUT /sys/user 修改
|
|
||||||
* PUT /sys/user/updateStatus 启用/停用
|
* PUT /sys/user/updateStatus 启用/停用
|
||||||
* PUT /sys/user/password 修改密码(自己)
|
|
||||||
* PUT /sys/user/resetPassword 重置密码(管理员)
|
* PUT /sys/user/resetPassword 重置密码(管理员)
|
||||||
* DELETE /sys/user 删除
|
* DELETE /sys/user 删除 (支持批量)
|
||||||
*
|
*
|
||||||
* 字段(来自 SysUserDTO):
|
* 字段(来自 SysUserDTO):
|
||||||
* id / username / password(WRITE_ONLY) / realName / headUrl
|
* id / username / realName / headUrl / gender / email / mobile / deptId
|
||||||
* gender(0=男/1=女/2=保密) / email / mobile / deptId
|
* superAdmin(0=否/1=是) / status(0=停用/1=正常) / employeeId(工号)
|
||||||
* superAdmin(0=否/1=是) / status(0=停用/1=正常) / remark
|
* userType(1=试用/2=正式/3=外包/99=AI公司) / createDate / deptName / roleNames
|
||||||
* identifier / employeeId(工号) / userType(1=试用/2=正式/3=外包/99=AI公司)
|
|
||||||
* joinDate / invitationUrl / invitationCode / xcxQrCode / operationId
|
|
||||||
* createDate / deptName / roleNames / roleIdList / postIdList
|
|
||||||
*
|
*
|
||||||
* 设计规范:§16 列表页 + §17.7 紧凑弹窗 + §17.8 主题色弹窗 + §17.9 列优化
|
* UI 标准对齐企业列表: 头像渐变单元 / 两行信息列 / 圆点状态胶囊(全局 --st-* 变量暗色自适应) /
|
||||||
|
* 搜索栏补用户类型下拉 / selection 批量删除 / 清理老式死样式
|
||||||
*/
|
*/
|
||||||
import { onMounted, ref, reactive } from 'vue'
|
import { onMounted, ref, reactive } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request'
|
import { User, Phone, Clock } from '@element-plus/icons-vue'
|
||||||
|
import { httpGet, httpPut, httpDelete } from '@/utils/request'
|
||||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||||
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||||
import SearchBar from '@/components/SearchBar/SearchBar.vue'
|
import SearchBar from '@/components/SearchBar/SearchBar.vue'
|
||||||
@ -35,42 +30,38 @@ interface SysUser {
|
|||||||
username: string
|
username: string
|
||||||
realName: string
|
realName: string
|
||||||
headUrl?: string | null
|
headUrl?: string | null
|
||||||
gender?: number | null
|
|
||||||
email?: string | null
|
email?: string | null
|
||||||
mobile?: string | null
|
mobile?: string | null
|
||||||
deptId?: string | null
|
|
||||||
deptName?: string | null
|
deptName?: string | null
|
||||||
superAdmin?: number
|
superAdmin?: number
|
||||||
status: 0 | 1
|
status: 0 | 1
|
||||||
remark?: string | null
|
|
||||||
identifier?: string | null
|
|
||||||
employeeId?: string | null
|
employeeId?: string | null
|
||||||
userType?: number
|
userType?: number
|
||||||
joinDate?: string | number | null
|
|
||||||
createDate: string | number
|
createDate: string | number
|
||||||
roleNames?: string | null
|
roleNames?: string | null
|
||||||
roleIdList?: number[] | null
|
|
||||||
postIdList?: number[] | null
|
|
||||||
raw: any
|
raw: any
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_MAP: Record<number, { label: string; type: string }> = {
|
const STATUS_META: Record<number, { label: string; color: string; bg: string }> = {
|
||||||
1: { label: '正常', type: 'success' },
|
1: { label: '正常', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
|
||||||
0: { label: '停用', type: 'danger' }
|
0: { label: '停用', color: 'var(--st-err-fg)', bg: 'var(--st-err-bg)' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const USER_TYPE_MAP: Record<number, { label: string; type: string }> = {
|
const USER_TYPE_MAP: Record<number, { label: string; color: string; bg: string }> = {
|
||||||
1: { label: '试用期', type: 'info' },
|
1: { label: '试用期', color: 'var(--st-lv4-fg)', bg: 'var(--st-lv4-bg)' },
|
||||||
2: { label: '正式', type: 'success' },
|
2: { label: '正式', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
|
||||||
3: { label: '外包', type: 'warning' },
|
3: { label: '外包', color: 'var(--st-warn-fg)', bg: 'var(--st-warn-bg)' },
|
||||||
99: { label: 'AI 公司', type: 'info' }
|
99: { label: 'AI 公司', color: 'var(--st-lv2-fg)', bg: 'var(--st-lv2-bg)' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const GENDER_MAP: Record<number, string> = {
|
// 头像品牌渐变色组 (无 headUrl 时交替使用, 与企业列表同源风格)
|
||||||
0: '男',
|
const AVATAR_GRADIENTS = [
|
||||||
1: '女',
|
'linear-gradient(135deg, #0b5394, #6fb1e8)',
|
||||||
2: '保密'
|
'linear-gradient(135deg, #2563eb, #93c5fd)',
|
||||||
}
|
'linear-gradient(135deg, #0e7490, #67e8f9)',
|
||||||
|
'linear-gradient(135deg, #7c3aed, #c4b5fd)',
|
||||||
|
'linear-gradient(135deg, #be123c, #fda4af)'
|
||||||
|
]
|
||||||
|
|
||||||
const list = ref<SysUser[]>([])
|
const list = ref<SysUser[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
@ -78,6 +69,8 @@ const loading = ref(false)
|
|||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const limit = ref(10)
|
const limit = ref(10)
|
||||||
const loadError = ref<string | null>(null)
|
const loadError = ref<string | null>(null)
|
||||||
|
const selectedIds = ref<string[]>([])
|
||||||
|
const selectedCount = ref(0)
|
||||||
|
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
username: '',
|
username: '',
|
||||||
@ -88,6 +81,14 @@ const resetPwdVisible = ref(false)
|
|||||||
const resetPwdTarget = ref<SysUser | null>(null)
|
const resetPwdTarget = ref<SysUser | null>(null)
|
||||||
const newPassword = ref('')
|
const newPassword = ref('')
|
||||||
|
|
||||||
|
function avatarStyle(row: SysUser, index: number) {
|
||||||
|
return { background: AVATAR_GRADIENTS[index % AVATAR_GRADIENTS.length] }
|
||||||
|
}
|
||||||
|
function initial(row: SysUser): string {
|
||||||
|
const name = row.realName && row.realName !== '--' ? row.realName : row.username
|
||||||
|
return (name || '用').trim().charAt(0)
|
||||||
|
}
|
||||||
|
|
||||||
function fmtDate(ts?: string | number | null) {
|
function fmtDate(ts?: string | number | null) {
|
||||||
if (!ts) return '--'
|
if (!ts) return '--'
|
||||||
const n = typeof ts === 'string' ? Date.parse(ts) : ts
|
const n = typeof ts === 'string' ? Date.parse(ts) : ts
|
||||||
@ -113,22 +114,14 @@ async function load() {
|
|||||||
username: it.username || '--',
|
username: it.username || '--',
|
||||||
realName: it.realName || '--',
|
realName: it.realName || '--',
|
||||||
headUrl: it.headUrl,
|
headUrl: it.headUrl,
|
||||||
gender: it.gender,
|
|
||||||
email: it.email,
|
|
||||||
mobile: it.mobile,
|
mobile: it.mobile,
|
||||||
deptId: it.deptId ? String(it.deptId) : null,
|
|
||||||
deptName: it.deptName || '--',
|
deptName: it.deptName || '--',
|
||||||
superAdmin: it.superAdmin ?? 0,
|
superAdmin: it.superAdmin ?? 0,
|
||||||
status: (it.status ?? 0) as 0 | 1,
|
status: (it.status ?? 0) as 0 | 1,
|
||||||
remark: it.remark,
|
|
||||||
identifier: it.identifier,
|
|
||||||
employeeId: it.employeeId || '--',
|
employeeId: it.employeeId || '--',
|
||||||
userType: it.userType,
|
userType: it.userType,
|
||||||
joinDate: it.joinDate,
|
|
||||||
createDate: it.createDate,
|
createDate: it.createDate,
|
||||||
roleNames: it.roleNames,
|
roleNames: it.roleNames,
|
||||||
roleIdList: it.roleIdList,
|
|
||||||
postIdList: it.postIdList,
|
|
||||||
raw: it
|
raw: it
|
||||||
}))
|
}))
|
||||||
total.value = data.total || 0
|
total.value = data.total || 0
|
||||||
@ -151,6 +144,7 @@ function onReset() {
|
|||||||
}
|
}
|
||||||
function onPageChange(p: number) { page.value = p; load() }
|
function onPageChange(p: number) { page.value = p; load() }
|
||||||
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
|
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
|
||||||
|
function onSelectionChange(rows: any[]) { selectedIds.value = rows.map((r: any) => r.id); selectedCount.value = rows.length }
|
||||||
|
|
||||||
async function toggleStatus(row: SysUser) {
|
async function toggleStatus(row: SysUser) {
|
||||||
const newStatus = row.status === 1 ? 0 : 1
|
const newStatus = row.status === 1 ? 0 : 1
|
||||||
@ -165,10 +159,7 @@ async function toggleStatus(row: SysUser) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await httpPut('/sys/user/updateStatus', {
|
await httpPut('/sys/user/updateStatus', { id: Number(row.id), status: newStatus })
|
||||||
id: Number(row.id),
|
|
||||||
status: newStatus
|
|
||||||
})
|
|
||||||
ElMessage.success(`${action}成功`)
|
ElMessage.success(`${action}成功`)
|
||||||
load()
|
load()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@ -184,14 +175,8 @@ function resetPwdHandle(row: SysUser) {
|
|||||||
|
|
||||||
async function resetPwdConfirm() {
|
async function resetPwdConfirm() {
|
||||||
if (!resetPwdTarget.value) return
|
if (!resetPwdTarget.value) return
|
||||||
if (!newPassword.value.trim()) {
|
if (!newPassword.value.trim()) { ElMessage.warning('请输入新密码'); return }
|
||||||
ElMessage.warning('请输入新密码')
|
if (newPassword.value.length < 6) { ElMessage.warning('密码至少 6 位'); return }
|
||||||
return
|
|
||||||
}
|
|
||||||
if (newPassword.value.length < 6) {
|
|
||||||
ElMessage.warning('密码至少 6 位')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await httpPut('/sys/user/resetPassword', {
|
await httpPut('/sys/user/resetPassword', {
|
||||||
id: Number(resetPwdTarget.value.id),
|
id: Number(resetPwdTarget.value.id),
|
||||||
@ -208,28 +193,35 @@ async function resetPwdConfirm() {
|
|||||||
|
|
||||||
async function deleteHandle(row: SysUser) {
|
async function deleteHandle(row: SysUser) {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(`确认删除用户 ${row.username} 吗?删除后无法恢复。`, '删除确认',
|
||||||
`确认删除用户 ${row.username} 吗?删除后无法恢复。`,
|
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' })
|
||||||
'删除确认',
|
} catch { return }
|
||||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await httpDelete(`/sys/user/${row.id}`)
|
await httpDelete(`/sys/user/${row.id}`)
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success('删除成功')
|
||||||
load()
|
load()
|
||||||
} catch (e: any) {
|
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '删除失败') }
|
||||||
ElMessage.error(e?.msg || e?.message || '删除失败')
|
}
|
||||||
|
|
||||||
|
async function batchDelete() {
|
||||||
|
if (!selectedIds.value.length) { ElMessage.warning('请先选择用户'); return }
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认删除已选 ${selectedIds.value.length} 个用户?删除后无法恢复。`, '批量删除确认',
|
||||||
|
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' })
|
||||||
|
} catch { return }
|
||||||
|
let ok = 0
|
||||||
|
for (const id of selectedIds.value) {
|
||||||
|
try { await httpDelete(`/sys/user/${id}`); ok++ } catch {/* 单条失败跳过 */}
|
||||||
}
|
}
|
||||||
|
ElMessage.success(`已删除 ${ok} 个用户`)
|
||||||
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => { load() })
|
onMounted(() => { load() })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="rr-sys-user">
|
<div class="rr-page">
|
||||||
<PageHeader title="后台用户" :sub="`共 ${total} 个用户`">
|
<PageHeader title="后台用户" :sub="`共 ${total} 个用户`">
|
||||||
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
|
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
@ -237,68 +229,116 @@ onMounted(() => { load() })
|
|||||||
<SearchBar
|
<SearchBar
|
||||||
v-model:filters="filters"
|
v-model:filters="filters"
|
||||||
:fields='[
|
:fields='[
|
||||||
{ key: "username", label: "用户名", type: "text", placeholder: "用户名" }
|
{ key: "username", label: "用户名", type: "text", placeholder: "用户名" },
|
||||||
|
{ key: "userType", label: "用户类型", type: "select", placeholder: "全部类型",
|
||||||
|
options: [
|
||||||
|
{ value: 1, label: "试用期" },
|
||||||
|
{ value: 2, label: "正式" },
|
||||||
|
{ value: 3, label: "外包" },
|
||||||
|
{ value: 99, label: "AI 公司" }
|
||||||
|
] }
|
||||||
]'
|
]'
|
||||||
@search="onSearch"
|
@search="onSearch"
|
||||||
@reset="onReset"
|
@reset="onReset"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="rr-list-card">
|
<div class="rr-list-card">
|
||||||
|
<!-- 批量操作栏 -->
|
||||||
|
<div class="batch-bar" :class="{ 'has-selection': selectedCount > 0 }">
|
||||||
|
<span v-if="selectedCount > 0" class="batch-count">
|
||||||
|
已选 <strong>{{ selectedCount }}</strong> 个用户
|
||||||
|
</span>
|
||||||
|
<span v-else class="batch-hint">勾选用户后可批量删除</span>
|
||||||
|
<span class="batch-actions">
|
||||||
|
<el-button size="small" type="danger" plain :disabled="!selectedCount" @click="batchDelete">批量删除</el-button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
:columns="[
|
:columns="[
|
||||||
{ prop: 'username', label: '用户名', width: 200, slot: 'username' },
|
{ type: 'selection', width: 48, label: '' },
|
||||||
{ prop: 'realName', label: '姓名', width: 140 },
|
{ label: '用户', minWidth: 220, slot: 'user' },
|
||||||
{ prop: 'employeeId', label: '工号', width: 120 },
|
{ prop: 'mobile', label: '手机号', width: 130, slot: 'mobile' },
|
||||||
{ prop: 'mobile', label: '手机号', width: 130 },
|
{ label: '部门 / 角色', minWidth: 180, slot: 'deptRole' },
|
||||||
{ prop: 'userType', label: '类型', width: 100, slot: 'userType' },
|
{ prop: 'userType', label: '类型', width: 100, slot: 'userType', align: 'center' },
|
||||||
{ prop: 'status', label: '状态', width: 80, slot: 'status' },
|
{ prop: 'status', label: '状态', width: 90, slot: 'status', align: 'center' },
|
||||||
{ prop: 'createDate', label: '创建时间', width: 170, slot: 'createDate' },
|
{ label: '创建时间', width: 165, slot: 'createDate' },
|
||||||
{ label: '操作', width: 140, slot: 'action' }
|
{ label: '操作', width: 170, slot: 'action', fixed: 'right', align: 'center' }
|
||||||
]"
|
]"
|
||||||
:data="list" :borderless="true"
|
:data="list"
|
||||||
|
:borderless="true"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:total="total"
|
:total="total"
|
||||||
:page="page"
|
:page="page"
|
||||||
:limit="limit"
|
:limit="limit"
|
||||||
:show-pagination="true" :page-sizes="[10, 20, 50, 100]"
|
:show-pagination="true"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
:error="loadError"
|
:error="loadError"
|
||||||
empty-text="暂无用户"
|
empty-text="暂无用户"
|
||||||
row-key="id"
|
row-key="id"
|
||||||
@page-change="onPageChange"
|
@page-change="onPageChange"
|
||||||
@limit-change="onLimitChange"
|
@limit-change="onLimitChange"
|
||||||
|
@selection-change="onSelectionChange"
|
||||||
>
|
>
|
||||||
<template #username="{ row }">
|
<!-- 用户单元: 头像 + 用户名 + 姓名/工号 -->
|
||||||
<code class="rr-username-code">{{ row.username }}</code>
|
<template #user="{ row, index }">
|
||||||
|
<div class="user-cell">
|
||||||
|
<span class="user-avatar" :style="avatarStyle(row, index)">
|
||||||
|
<el-image v-if="row.headUrl" :src="row.headUrl" fit="cover" class="user-avatar-img" />
|
||||||
|
<template v-else>{{ initial(row) }}</template>
|
||||||
|
</span>
|
||||||
|
<span class="user-text">
|
||||||
|
<span class="user-name">
|
||||||
|
{{ row.username }}
|
||||||
|
<el-tag v-if="row.superAdmin === 1" size="small" type="warning" effect="light" class="super-tag">超管</el-tag>
|
||||||
|
</span>
|
||||||
|
<span class="user-sub">{{ row.realName }}{{ row.employeeId && row.employeeId !== '--' ? ` · ${row.employeeId}` : '' }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- 手机号 -->
|
||||||
|
<template #mobile="{ row }">
|
||||||
|
<span class="phone-cell"><el-icon><Phone /></el-icon>{{ row.mobile || '--' }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 部门/角色 -->
|
||||||
|
<template #deptRole="{ row }">
|
||||||
|
<div class="two-line">
|
||||||
|
<span class="line-main">{{ row.deptName }}</span>
|
||||||
|
<span class="line-sub">{{ row.roleNames || '未分配角色' }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 类型 -->
|
||||||
<template #userType="{ row }">
|
<template #userType="{ row }">
|
||||||
<el-tag
|
<span v-if="row.userType != null && USER_TYPE_MAP[row.userType]" class="chip"
|
||||||
v-if="row.userType != null && USER_TYPE_MAP[row.userType]"
|
:style="{ color: USER_TYPE_MAP[row.userType].color, background: USER_TYPE_MAP[row.userType].bg }">
|
||||||
:type="USER_TYPE_MAP[row.userType].type as any"
|
{{ USER_TYPE_MAP[row.userType].label }}
|
||||||
size="small"
|
</span>
|
||||||
effect="light"
|
|
||||||
>{{ USER_TYPE_MAP[row.userType].label }}</el-tag>
|
|
||||||
<span v-else class="rr-muted">--</span>
|
<span v-else class="rr-muted">--</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- 状态: 圆点胶囊 -->
|
||||||
<template #status="{ row }">
|
<template #status="{ row }">
|
||||||
<el-tag
|
<span class="status-tag"
|
||||||
:type="STATUS_MAP[row.status].type as any"
|
:style="{ color: STATUS_META[row.status].color, background: STATUS_META[row.status].bg }">
|
||||||
size="small"
|
<span class="status-dot"></span>{{ STATUS_META[row.status].label }}
|
||||||
effect="light"
|
</span>
|
||||||
>{{ STATUS_MAP[row.status].label }}</el-tag>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- 创建时间 -->
|
||||||
<template #createDate="{ row }">
|
<template #createDate="{ row }">
|
||||||
<span class="rr-time">{{ fmtDate(row.createDate) }}</span>
|
<span class="date-cell"><el-icon><Clock /></el-icon>{{ fmtDate(row.createDate) }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- 操作 -->
|
||||||
<template #action="{ row }">
|
<template #action="{ row }">
|
||||||
<TableActions
|
<TableActions
|
||||||
:row="row"
|
:row="row"
|
||||||
:actions="[
|
:actions="[
|
||||||
{ label: (r: any) => r.status === 1 ? '停用' : '启用', type: (r: any) => r.status === 1 ? 'warning' : 'success', onClick: toggleStatus },
|
{ label: (r: any) => r.status === 1 ? '停用' : '启用', type: (r: any) => r.status === 1 ? 'warning' : 'success', onClick: toggleStatus },
|
||||||
{ label: '重置密码', type: 'primary', onClick: resetPwdHandle },
|
{ label: '重置密码', type: 'info', onClick: resetPwdHandle },
|
||||||
{ label: '删除', type: 'danger', onClick: deleteHandle }
|
{ label: '删除', type: 'danger', onClick: deleteHandle }
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
@ -311,11 +351,10 @@ onMounted(() => { load() })
|
|||||||
v-model="resetPwdVisible"
|
v-model="resetPwdVisible"
|
||||||
title="重置密码"
|
title="重置密码"
|
||||||
width="480"
|
width="480"
|
||||||
:style="{ borderRadius: '12px', overflow: 'hidden' }"
|
|
||||||
align-center
|
align-center
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
>
|
>
|
||||||
<p v-if="resetPwdTarget" style="margin-bottom: 16px;">
|
<p v-if="resetPwdTarget" style="margin: 0 0 16px; color: var(--ink-2)">
|
||||||
用户: <code class="rr-username-code">{{ resetPwdTarget.username }}</code>
|
用户: <code class="rr-username-code">{{ resetPwdTarget.username }}</code>
|
||||||
({{ resetPwdTarget.realName }})
|
({{ resetPwdTarget.realName }})
|
||||||
</p>
|
</p>
|
||||||
@ -330,9 +369,8 @@ onMounted(() => { load() })
|
|||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<el-alert type="warning" :closable="false" style="margin-top: 8px;">
|
<el-alert type="warning" :closable="false" style="margin-top: 8px">
|
||||||
<p>重置后请通过安全渠道告知用户</p>
|
<p style="margin: 0">重置后请通过安全渠道告知用户,下次登录需使用新密码</p>
|
||||||
<p>用户下次登录需使用新密码</p>
|
|
||||||
</el-alert>
|
</el-alert>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="resetPwdVisible = false">取消</el-button>
|
<el-button @click="resetPwdVisible = false">取消</el-button>
|
||||||
@ -342,78 +380,64 @@ onMounted(() => { load() })
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped lang="scss">
|
||||||
.rr-sys-user {
|
.rr-page { padding: 16px; }
|
||||||
padding: 16px;
|
|
||||||
|
/* ===== 批量操作栏 (对齐企业列表) ===== */
|
||||||
|
.batch-bar {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 12px 16px; border-bottom: 1px solid var(--border);
|
||||||
|
transition: background .2s;
|
||||||
|
}
|
||||||
|
.batch-bar.has-selection { background: var(--primary-soft, #e0ecff); }
|
||||||
|
.batch-count { font-size: 13px; color: var(--ink-2); }
|
||||||
|
.batch-count strong { color: var(--primary); font-size: 15px; }
|
||||||
|
.batch-hint { font-size: 12px; color: var(--ink-3); }
|
||||||
|
.batch-actions { margin-left: auto; }
|
||||||
|
|
||||||
|
/* ===== 用户单元 ===== */
|
||||||
|
.user-cell { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.user-avatar {
|
||||||
|
flex: 0 0 38px; width: 38px; height: 38px; border-radius: 10px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: #fff; font-size: 16px; font-weight: 700; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.user-avatar-img { width: 100%; height: 100%; border-radius: 10px; }
|
||||||
|
.user-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||||
|
.user-name {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
font-size: 14px; font-weight: 600; color: var(--ink);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.super-tag { margin-left: 2px; }
|
||||||
|
.user-sub {
|
||||||
|
font-size: 12px; color: var(--ink-3);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rr-page-header {
|
/* ===== 单元格通用 ===== */
|
||||||
display: flex;
|
.two-line { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||||
align-items: center;
|
.line-main { font-size: 13px; color: var(--ink); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
justify-content: space-between;
|
.line-sub { font-size: 12px; color: var(--ink-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
margin-bottom: 14px;
|
.phone-cell { display: inline-flex; align-items: center; gap: 5px; font-size: 13px; color: var(--ink); font-family: 'SF Mono', Menlo, Consolas, monospace; }
|
||||||
}
|
.date-cell { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--ink-2); font-family: 'SF Mono', Menlo, Consolas, monospace; }
|
||||||
|
|
||||||
.rr-page-titles {
|
/* ===== 类型/状态胶囊 (对齐企业列表, 使用全局 --st-* 变量暗色自适应) ===== */
|
||||||
display: flex;
|
.chip {
|
||||||
flex-direction: column;
|
display: inline-flex; align-items: center; width: fit-content;
|
||||||
gap: 2px;
|
font-size: 12px; font-weight: 500; padding: 2px 12px; border-radius: 20px; white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.status-tag {
|
||||||
.rr-page-title {
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
margin: 0;
|
font-size: 12px; font-weight: 500; padding: 3px 12px; border-radius: 20px; white-space: nowrap;
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rr-page-sub {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rr-page-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rr-filters-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: var(--el-fill-color-light);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rr-username-input { width: 240px; }
|
|
||||||
.rr-type-select { width: 140px; }
|
|
||||||
|
|
||||||
.rr-list-card {
|
|
||||||
background: var(--el-bg-color);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 4px;
|
|
||||||
}
|
}
|
||||||
|
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||||
|
|
||||||
.rr-username-code {
|
.rr-username-code {
|
||||||
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||||
font-size: 12px;
|
font-size: 12px; padding: 2px 6px;
|
||||||
padding: 2px 6px;
|
background: var(--fill-color-light, var(--surface-2));
|
||||||
background: var(--el-fill-color-light);
|
border-radius: 4px; color: var(--el-color-primary);
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--el-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rr-time {
|
|
||||||
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--el-text-color-regular);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rr-muted {
|
|
||||||
color: var(--el-text-color-placeholder);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
}
|
||||||
|
.rr-muted { color: var(--ink-3); font-size: 12px; }
|
||||||
</style>
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
453
src/modules/user/EnterpriseScoreListView.vue
Normal file
453
src/modules/user/EnterpriseScoreListView.vue
Normal file
@ -0,0 +1,453 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 企业评分与等级管理 — admin
|
||||||
|
*
|
||||||
|
* 数据源: /user/enterprisescore/{page,stats,logs,score}(user-server)
|
||||||
|
* 规则: 初始60分(铜牌) / ≥80金 / [70,80)银 / <70铜;评分维度 配合度10% + 中标数量90%,单次录入≤10,实际变动=录入×权重
|
||||||
|
*/
|
||||||
|
import { ref, onMounted, reactive, computed } from 'vue'
|
||||||
|
import { Trophy, Connection, Top, Bottom, Warning } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { httpGet, httpPost } from '@/utils/request'
|
||||||
|
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||||
|
import SearchBar from '@/components/SearchBar/SearchBar.vue'
|
||||||
|
import ListTabs from '@/components/ListTabs/ListTabs.vue'
|
||||||
|
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||||
|
import TableActions from '@/components/TableActions/TableActions.vue'
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id: string | number
|
||||||
|
enterprise_name: string
|
||||||
|
current_score: number
|
||||||
|
level: number
|
||||||
|
last_score_time: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVEL_META: Record<number, { label: string; icon?: string; color: string; bg: string; border: string }> = {
|
||||||
|
0: { label: '待整改供应商', icon: 'warning', color: '#b91c1c', bg: 'linear-gradient(135deg,#fef2f2,#fee2e2)', border: '#ef4444' },
|
||||||
|
3: { label: '金牌供应商', color: '#b45309', bg: 'linear-gradient(135deg,#fef3c7,#fde68a)', border: '#f59e0b' },
|
||||||
|
2: { label: '银牌供应商', color: '#475569', bg: 'linear-gradient(135deg,#f1f5f9,#e2e8f0)', border: '#94a3b8' },
|
||||||
|
1: { label: '铜牌供应商', color: '#9a3412', bg: 'linear-gradient(135deg,#ffedd5,#fed7aa)', border: '#fb923c' },
|
||||||
|
}
|
||||||
|
function levelMeta(lv: number) { return LEVEL_META[lv] || LEVEL_META[1] }
|
||||||
|
|
||||||
|
const LEVEL_TABS = [
|
||||||
|
{ value: '', label: '全部企业' },
|
||||||
|
{ value: '3', label: '金牌 (≥80分)' },
|
||||||
|
{ value: '2', label: '银牌 (70~80分)' },
|
||||||
|
{ value: '1', label: '铜牌 (60~70分)' },
|
||||||
|
{ value: '0', label: '待整改 (<60分)' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const stats = ref<any>({ gold: 0, silver: 0, bronze: 0, fail: 0, total: 0 })
|
||||||
|
const list = ref<Row[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const page = ref(1)
|
||||||
|
const limit = ref(10)
|
||||||
|
const filters = reactive({ enterpriseName: '', level: '' })
|
||||||
|
|
||||||
|
const columns: any = [
|
||||||
|
{ prop: 'enterprise_name', label: '企业名称', minWidth: 240, slot: 'name' },
|
||||||
|
{ prop: 'level', label: '等级', width: 130, align: 'center', slot: 'level' },
|
||||||
|
{ prop: 'current_score', label: '当前积分', width: 120, align: 'center', slot: 'score' },
|
||||||
|
{ prop: 'score_bar', label: '分数区间', width: 220, align: 'center', slot: 'bar' },
|
||||||
|
{ prop: 'last_score_time', label: '最近评分', width: 160, slot: 'time' },
|
||||||
|
{ label: '操作', width: 160, align: 'center', slot: 'action' }
|
||||||
|
]
|
||||||
|
const tabItems = LEVEL_TABS.map(t => ({ key: t.value, label: t.label }))
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const params: any = { page: page.value, limit: limit.value }
|
||||||
|
if (filters.enterpriseName) params.enterpriseName = filters.enterpriseName
|
||||||
|
const lv = Number(filters.level)
|
||||||
|
if (lv) params.level = lv
|
||||||
|
const r: any = await httpGet('/user/enterprisescore/page', params)
|
||||||
|
list.value = r?.list || []
|
||||||
|
const t = r?.total
|
||||||
|
total.value = typeof t === 'string' ? parseInt(t, 10) : (Number(t) || 0)
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.msg || e?.message || '加载失败'
|
||||||
|
list.value = []
|
||||||
|
} finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
const r: any = await httpGet('/user/enterprisescore/stats')
|
||||||
|
if (r) stats.value = r
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearch() { page.value = 1; load() }
|
||||||
|
function onReset() { filters.enterpriseName = ''; filters.level = ''; load() }
|
||||||
|
function onPageChange(p: number) { page.value = p; load() }
|
||||||
|
function onLimitChange(l: number) { limit.value = l; load() }
|
||||||
|
function onLevelTab(v: string) { filters.level = v; onSearch() }
|
||||||
|
|
||||||
|
// ===== 评分(复用企业页同款弹窗实现) =====
|
||||||
|
const DIMENSIONS = [
|
||||||
|
{ id: 1, name: '配合度', weight: 10, desc: '沟通响应、履约配合、售后态度' },
|
||||||
|
{ id: 2, name: '中标数量', weight: 90, desc: '参与报价并中标的业务表现' },
|
||||||
|
]
|
||||||
|
const scoreDialog = ref(false)
|
||||||
|
const scoreRow = ref<Row | null>(null)
|
||||||
|
const scoreLoading = ref(false)
|
||||||
|
const scoreInfo = ref<any>(null)
|
||||||
|
const scoreLogs = ref<any[]>([])
|
||||||
|
const scoreForm = reactive({ dimension: 2, action: 1, rawScore: 5, reason: '' })
|
||||||
|
const scoreSubmitting = ref(false)
|
||||||
|
const logDrawer = ref(false)
|
||||||
|
const logList = ref<any[]>([])
|
||||||
|
const logTotal = ref(0)
|
||||||
|
const logLoading = ref(false)
|
||||||
|
const logPage = ref(1)
|
||||||
|
|
||||||
|
function levelOfScore(score: number): number { return score >= 80 ? 3 : score >= 70 ? 2 : score >= 60 ? 1 : 0 }
|
||||||
|
function lvOf(v: any): number { const n = Number(v ?? 60); return n >= 80 ? 3 : n >= 70 ? 2 : n >= 60 ? 1 : 0 }
|
||||||
|
|
||||||
|
async function openScore(row: Row) {
|
||||||
|
scoreRow.value = row
|
||||||
|
scoreInfo.value = { score: Number(row.current_score), level: Number(row.level) }
|
||||||
|
scoreLogs.value = []
|
||||||
|
scoreForm.dimension = 2; scoreForm.action = 1; scoreForm.rawScore = 5; scoreForm.reason = ''
|
||||||
|
scoreDialog.value = true
|
||||||
|
scoreLoading.value = true
|
||||||
|
try {
|
||||||
|
const [info, logs]: any = await Promise.all([
|
||||||
|
httpGet('/user/enterprisescore/info', { enterpriseId: row.id }),
|
||||||
|
httpGet('/user/enterprisescore/logs', { enterpriseId: row.id, page: 1, limit: 5 })
|
||||||
|
])
|
||||||
|
if (info) scoreInfo.value = info
|
||||||
|
scoreLogs.value = logs?.list || []
|
||||||
|
} catch { /* 保底当前行数据 */ }
|
||||||
|
scoreLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const scoreChange = computed(() => {
|
||||||
|
const dim = DIMENSIONS.find(d => d.id === scoreForm.dimension)!
|
||||||
|
const change = Math.round((Number(scoreForm.rawScore) || 0) * dim.weight / 100)
|
||||||
|
return scoreForm.action === 1 ? Number(change.toFixed(2)) : -Number(change.toFixed(2))
|
||||||
|
})
|
||||||
|
const scoreAfter = computed(() => {
|
||||||
|
const base = Number(scoreInfo.value?.score ?? 60)
|
||||||
|
return Math.max(0, Math.min(100, Number((base + scoreChange.value).toFixed(2))))
|
||||||
|
})
|
||||||
|
const scoreLevelChange = computed(() => {
|
||||||
|
const before = levelOfScore(Number(scoreInfo.value?.score ?? 60))
|
||||||
|
const after = levelOfScore(scoreAfter.value)
|
||||||
|
return { before, after, changed: before !== after, up: after > before }
|
||||||
|
})
|
||||||
|
|
||||||
|
async function submitScore() {
|
||||||
|
if (!scoreRow.value) return
|
||||||
|
if (!(Number(scoreForm.rawScore) > 0)) { ElMessage.warning('请填写分值'); return }
|
||||||
|
scoreSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const r: any = await httpPost('/user/enterprisescore/score', {
|
||||||
|
enterpriseId: scoreRow.value.id, dimension: scoreForm.dimension, action: scoreForm.action,
|
||||||
|
rawScore: scoreForm.rawScore, reason: scoreForm.reason, operator: 'admin'
|
||||||
|
})
|
||||||
|
ElMessage.success(`评分成功: ${r?.scoreBefore} → ${r?.scoreAfter} 分`)
|
||||||
|
scoreDialog.value = false
|
||||||
|
await Promise.all([load(), loadStats()])
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '评分失败') }
|
||||||
|
finally { scoreSubmitting.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openLogs(row: Row) {
|
||||||
|
scoreRow.value = row
|
||||||
|
logDrawer.value = true
|
||||||
|
logPage.value = 1
|
||||||
|
await loadLogs()
|
||||||
|
}
|
||||||
|
async function loadLogs() {
|
||||||
|
if (!scoreRow.value) return
|
||||||
|
logLoading.value = true
|
||||||
|
try {
|
||||||
|
const r: any = await httpGet('/user/enterprisescore/logs', { enterpriseId: scoreRow.value.id, page: logPage.value, limit: 10 })
|
||||||
|
logList.value = r?.list || []
|
||||||
|
logTotal.value = Number(r?.total || 0)
|
||||||
|
} catch { logList.value = [] }
|
||||||
|
finally { logLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(v: any) { return String(v || '').replace('T', ' ').slice(0, 16) }
|
||||||
|
function logDim(v: number) { return v === 1 ? '配合度' : '中标数量' }
|
||||||
|
|
||||||
|
onMounted(() => { load(); loadStats() })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rr-page">
|
||||||
|
<PageHeader title="企业评分与等级" sub="履约评分 · 权重:配合度 10% + 中标数量 90% · 单次录入 ≤10 分 · 初始 60 分(铜牌)" />
|
||||||
|
|
||||||
|
<!-- 统计卡 -->
|
||||||
|
<div class="lvl-stats">
|
||||||
|
<div class="lvl-card">
|
||||||
|
<div class="lvl-num">{{ stats.total }}</div>
|
||||||
|
<div class="lvl-cap">已认证企业</div>
|
||||||
|
</div>
|
||||||
|
<div class="lvl-card gold">
|
||||||
|
<div class="lvl-badge"><el-icon><Trophy /></el-icon>金牌</div>
|
||||||
|
<div class="lvl-num">{{ stats.gold }}</div>
|
||||||
|
<div class="lvl-cap">≥80 分</div>
|
||||||
|
</div>
|
||||||
|
<div class="lvl-card silver">
|
||||||
|
<div class="lvl-badge"><el-icon><Trophy /></el-icon>银牌</div>
|
||||||
|
<div class="lvl-num">{{ stats.silver }}</div>
|
||||||
|
<div class="lvl-cap">70 ~ 80 分</div>
|
||||||
|
</div>
|
||||||
|
<div class="lvl-card bronze">
|
||||||
|
<div class="lvl-badge"><el-icon><Trophy /></el-icon>铜牌</div>
|
||||||
|
<div class="lvl-num">{{ stats.bronze }}</div>
|
||||||
|
<div class="lvl-cap">60 ~ 70 分(初始 60)</div>
|
||||||
|
</div>
|
||||||
|
<div class="lvl-card fail">
|
||||||
|
<div class="lvl-badge"><el-icon><Warning /></el-icon>待整改</div>
|
||||||
|
<div class="lvl-num">{{ stats.fail }}</div>
|
||||||
|
<div class="lvl-cap"><60 分</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ListTabs v-model="filters.level" :items="tabItems" @change="onLevelTab" />
|
||||||
|
|
||||||
|
<SearchBar
|
||||||
|
v-model:filters="filters"
|
||||||
|
:fields="[{ key: 'enterpriseName', label: '企业名称', placeholder: '请输入企业名称' }]"
|
||||||
|
@search="onSearch"
|
||||||
|
@reset="onReset"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:data="list"
|
||||||
|
:columns="columns"
|
||||||
|
:loading="loading"
|
||||||
|
:error="error"
|
||||||
|
:total="total"
|
||||||
|
:page="page"
|
||||||
|
:limit="limit"
|
||||||
|
@page-change="onPageChange"
|
||||||
|
@limit-change="onLimitChange"
|
||||||
|
>
|
||||||
|
<template #name="{ row }">
|
||||||
|
<span class="rr-name">{{ row.enterprise_name }}</span>
|
||||||
|
</template>
|
||||||
|
<template #level="{ row }">
|
||||||
|
<span class="lvl-pill" :style="{ background: levelMeta(row.level).bg, color: levelMeta(row.level).color, boxShadow: 'inset 0 0 0 1px ' + levelMeta(row.level).border }">
|
||||||
|
<el-icon style="font-size:13px"><Trophy /></el-icon>{{ levelMeta(row.level).label }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #score="{ row }">
|
||||||
|
<span class="rr-score">{{ Number(row.current_score) }}</span>
|
||||||
|
<span class="rr-score-cap">分</span>
|
||||||
|
</template>
|
||||||
|
<template #bar="{ row }">
|
||||||
|
<div class="score-track">
|
||||||
|
<div class="score-track-in" :style="{ width: Math.min(100, Number(row.current_score)) + '%', background: levelMeta(row.level).border }"></div>
|
||||||
|
<span class="score-track-mark" :style="{ left: '70%' }"></span>
|
||||||
|
<span class="score-track-mark" :style="{ left: '80%' }"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #time="{ row }">
|
||||||
|
<span class="rr-time">{{ row.last_score_time ? fmtTime(row.last_score_time) : '—' }}</span>
|
||||||
|
</template>
|
||||||
|
<template #action="{ row }">
|
||||||
|
<TableActions
|
||||||
|
:row="row"
|
||||||
|
:actions="[
|
||||||
|
{ label: '评分', type: 'primary', onClick: (r: any) => openScore(r) },
|
||||||
|
{ label: '评分记录', type: 'primary', onClick: (r: any) => openLogs(r) }
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<!-- 评分弹窗 -->
|
||||||
|
<el-dialog v-model="scoreDialog" title="企业履约评分" width="680px" destroy-on-close>
|
||||||
|
<div v-if="scoreLoading" style="padding:40px 0;text-align:center;color:var(--ink-3)">加载中...</div>
|
||||||
|
<template v-else-if="scoreRow">
|
||||||
|
<div class="sc-hero">
|
||||||
|
<div class="sc-hero-left">
|
||||||
|
<div class="sc-name">{{ scoreRow.enterprise_name }}</div>
|
||||||
|
<div class="sc-sub">当前 <b>{{ scoreInfo?.score ?? 60 }}</b> 分 · {{ levelMeta(scoreInfo?.level ?? 1).label }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="sc-badge" :style="{ background: levelMeta(scoreInfo?.level ?? 1).bg, color: levelMeta(scoreInfo?.level ?? 1).color }">
|
||||||
|
<el-icon style="font-size:18px"><Trophy /></el-icon>
|
||||||
|
{{ levelMeta(scoreInfo?.level ?? 1).label }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sc-scale">
|
||||||
|
<div class="sc-seg fail" :class="{ on: (scoreInfo?.level ?? 1) === 0 }" style="flex:0 0 60%"><span>待整改</span><i><60 分</i></div>
|
||||||
|
<div class="sc-seg bronze" :class="{ on: (scoreInfo?.level ?? 1) === 1 }" style="flex:0 0 10%; min-width:64px"><span>铜牌</span><i>60~70</i></div>
|
||||||
|
<div class="sc-seg silver" :class="{ on: (scoreInfo?.level ?? 1) === 2 }" style="flex:0 0 10%; min-width:64px"><span>银牌</span><i>70~80</i></div>
|
||||||
|
<div class="sc-seg gold" :class="{ on: (scoreInfo?.level ?? 1) === 3 }" style="flex:1"><span>金牌</span><i>≥80 分</i></div>
|
||||||
|
<div class="sc-marker" :style="{ left: Math.min(98, Math.max(2, Number(scoreInfo?.score ?? 60))) + '%' }">
|
||||||
|
<span class="sc-marker-tip">当前 {{ scoreInfo?.score ?? 60 }} 分</span>
|
||||||
|
<span class="sc-marker-dot"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sc-scale-cap">刻度按分数区间占比呈现,圆点即企业当前积分位置</div>
|
||||||
|
<div class="fee-section-title" style="margin-top:18px">评分维度</div>
|
||||||
|
<div class="fee-type-cards">
|
||||||
|
<div v-for="d in DIMENSIONS" :key="d.id" :class="['fee-type-card', { active: scoreForm.dimension === d.id }]" @click="scoreForm.dimension = d.id">
|
||||||
|
<div class="ftc-head">
|
||||||
|
<el-icon class="sc-dim-icon"><Trophy v-if="d.id === 2" /><Connection v-else /></el-icon>
|
||||||
|
<span class="ftc-check">✓</span>
|
||||||
|
</div>
|
||||||
|
<div class="ftc-title">{{ d.name }} <span class="sc-weight-chip">权重 {{ d.weight }}%</span></div>
|
||||||
|
<div class="ftc-desc">{{ d.desc }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fee-section-title">评分操作</div>
|
||||||
|
<div class="sc-op-row">
|
||||||
|
<div class="sc-seg2">
|
||||||
|
<button :class="['sc-seg2-btn', { plus: scoreForm.action === 1 }]" @click="scoreForm.action = 1"><el-icon style="font-size:15px"><Top /></el-icon>加分</button>
|
||||||
|
<button :class="['sc-seg2-btn', { minus: scoreForm.action === 2 }]" @click="scoreForm.action = 2"><el-icon style="font-size:15px"><Bottom /></el-icon>减分</button>
|
||||||
|
</div>
|
||||||
|
<el-input-number v-model="scoreForm.rawScore" :min="0.01" :max="10" :precision="2" :step="1" controls-position="right" style="width:150px" />
|
||||||
|
<span class="fee-unit">分</span>
|
||||||
|
<span class="fee-hint">录入分 × 权重 = 实际变动</span>
|
||||||
|
</div>
|
||||||
|
<el-input v-model="scoreForm.reason" placeholder="评分原因(如:季度履约优秀 / 中标 3 次)" maxlength="100" show-word-limit style="margin-top:10px" />
|
||||||
|
<div class="fee-result" style="margin-top:14px">
|
||||||
|
<div class="fr-left">
|
||||||
|
<div class="fr-cap">实际变动</div>
|
||||||
|
<div class="fr-val" :style="{ color: scoreForm.action === 1 ? 'var(--el-color-success)' : 'var(--el-color-danger)' }">{{ scoreChange > 0 ? '+' : '' }}{{ scoreChange }} 分</div>
|
||||||
|
<div class="fr-calc">{{ Number(scoreForm.rawScore || 0) }} 分 × {{ DIMENSIONS.find(d => d.id === scoreForm.dimension)?.weight }}% 权重</div>
|
||||||
|
</div>
|
||||||
|
<div class="fr-right">
|
||||||
|
<div class="fr-step"><span class="sc-arrow-text">{{ scoreInfo?.score ?? 60 }} 分 → <b>{{ scoreAfter }} 分</b></span></div>
|
||||||
|
<div class="fr-step" v-if="scoreLevelChange.changed">
|
||||||
|
<span class="sc-lv-change" :style="{ color: scoreLevelChange.up ? '#b45309' : '#b91c1c' }">
|
||||||
|
{{ levelMeta(scoreLevelChange.before).label }} → {{ levelMeta(scoreLevelChange.after).label }}({{ scoreLevelChange.up ? '升级' : '降级' }})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="fr-step" v-else><span class="sc-arrow-text">等级不变:{{ levelMeta(scoreLevelChange.after).label }}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="scoreDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="scoreSubmitting" @click="submitScore">确认评分</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 评分流水抽屉 -->
|
||||||
|
<el-drawer v-model="logDrawer" :title="`评分记录 — ${scoreRow?.enterprise_name || ''}`" size="560px">
|
||||||
|
<el-table :data="logList" v-loading="logLoading" size="small" border>
|
||||||
|
<el-table-column label="时间" width="140">
|
||||||
|
<template #default="{ row }"><span class="rr-time">{{ fmtTime(row.create_time) }}</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="维度" width="80">
|
||||||
|
<template #default="{ row }">{{ logDim(row.dimension) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="变动" width="90" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :style="{ color: Number(row.change_score) > 0 ? 'var(--el-color-success)' : 'var(--el-color-danger)', fontWeight: 700 }">
|
||||||
|
{{ Number(row.change_score) > 0 ? '+' : '' }}{{ Number(row.change_score).toFixed(2) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="分值变化" width="110" align="center">
|
||||||
|
<template #default="{ row }">{{ Number(row.score_before) }} → {{ Number(row.score_after) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="原因" min-width="130">
|
||||||
|
<template #default="{ row }"><span class="rr-muted">{{ row.reason }}</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作人" width="70">
|
||||||
|
<template #default="{ row }">{{ row.operator }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div v-if="logTotal > 10" class="log-pager">
|
||||||
|
<el-button size="small" :disabled="logPage <= 1" @click="logPage--; loadLogs()">上一页</el-button>
|
||||||
|
<span class="rr-muted">{{ logPage }} / {{ Math.ceil(logTotal / 10) }}</span>
|
||||||
|
<el-button size="small" :disabled="logPage >= Math.ceil(logTotal / 10)" @click="logPage++; loadLogs()">下一页</el-button>
|
||||||
|
</div>
|
||||||
|
</el-drawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.rr-page { max-width: 1500px; }
|
||||||
|
.rr-muted { color: var(--ink-3); }
|
||||||
|
.rr-name { font-weight: 600; color: var(--ink); }
|
||||||
|
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--ink-2); }
|
||||||
|
.rr-score { font-size: 17px; font-weight: 900; color: var(--ink); font-family: 'SF Mono', Menlo, Consolas, monospace; }
|
||||||
|
.rr-score-cap { font-size: 11px; color: var(--ink-3); margin-left: 2px; }
|
||||||
|
|
||||||
|
.lvl-stats { display: grid; grid-template-columns: repeat(5, 1fr); gap: 14px; margin-bottom: 16px; }
|
||||||
|
.lvl-card { border-radius: 12px; padding: 16px 18px; border: 1px solid var(--border-1, #e5e7eb); background: var(--bg-1, #fff); position: relative; overflow: hidden; }
|
||||||
|
.lvl-card.gold { background: linear-gradient(135deg, #fffbeb, #fef3c7); border-color: #f59e0b; }
|
||||||
|
.lvl-card.silver { background: linear-gradient(135deg, #f8fafc, #e2e8f0); border-color: #94a3b8; }
|
||||||
|
.lvl-card.bronze { background: linear-gradient(135deg, #fff7ed, #ffedd5); border-color: #fb923c; }
|
||||||
|
.lvl-card.fail { background: linear-gradient(135deg, #fef2f2, #fee2e2); border-color: #ef4444; }
|
||||||
|
.lvl-card.fail .lvl-badge { color: #b91c1c; }
|
||||||
|
.lvl-num { font-size: 30px; font-weight: 900; line-height: 1.1; color: var(--ink); }
|
||||||
|
.lvl-cap { font-size: 12px; color: var(--ink-3); margin-top: 2px; }
|
||||||
|
.lvl-badge { position: absolute; top: 12px; right: 14px; display: inline-flex; align-items: center; gap: 3px; font-size: 12px; font-weight: 800; }
|
||||||
|
.lvl-card.gold .lvl-badge { color: #b45309; }
|
||||||
|
.lvl-card.silver .lvl-badge { color: #475569; }
|
||||||
|
.lvl-card.bronze .lvl-badge { color: #9a3412; }
|
||||||
|
|
||||||
|
.lvl-pill { display: inline-flex; align-items: center; gap: 3px; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 800; white-space: nowrap; }
|
||||||
|
.score-track { position: relative; height: 10px; border-radius: 999px; background: rgba(0,0,0,.06); overflow: visible; }
|
||||||
|
.score-track-in { height: 100%; border-radius: 999px; transition: width .3s; }
|
||||||
|
.score-track-mark { position: absolute; top: -2px; width: 1.5px; height: 14px; background: rgba(0,0,0,.25); }
|
||||||
|
.log-pager { display: flex; align-items: center; justify-content: center; gap: 10px; margin-top: 12px; }
|
||||||
|
|
||||||
|
.fee-section-title { font-size: 13px; font-weight: 700; color: var(--ink); margin: 16px 0 8px; }
|
||||||
|
.fee-type-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.fee-type-card { position: relative; border: 1.5px solid var(--border-1, #e5e7eb); border-radius: 10px; padding: 12px 14px; cursor: pointer; transition: all .15s; background: var(--bg-1, #fff); }
|
||||||
|
.fee-type-card:hover { border-color: var(--primary); }
|
||||||
|
.fee-type-card.active { border-color: var(--primary); background: var(--primary-soft, #eff6ff); box-shadow: 0 0 0 1px var(--primary) inset; }
|
||||||
|
.ftc-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
|
||||||
|
.ftc-check { display: none; width: 18px; height: 18px; border-radius: 50%; background: var(--primary); color: #fff; font-size: 11px; text-align: center; line-height: 18px; }
|
||||||
|
.fee-type-card.active .ftc-check { display: block; }
|
||||||
|
.ftc-title { font-size: 13.5px; font-weight: 700; color: var(--ink); }
|
||||||
|
.ftc-desc { font-size: 12px; color: var(--ink-3); margin-top: 2px; }
|
||||||
|
.fee-unit { font-size: 14px; font-weight: 700; color: var(--ink); }
|
||||||
|
.fee-hint { font-size: 12px; color: var(--ink-3); }
|
||||||
|
.fee-result { margin-top: 14px; border-radius: 10px; padding: 14px 16px; background: linear-gradient(135deg, var(--primary-soft, #eff6ff), rgba(255,255,255,0)); border: 1px solid color-mix(in srgb, var(--primary) 18%, transparent); display: flex; gap: 20px; align-items: center; }
|
||||||
|
.fr-left { flex-shrink: 0; min-width: 150px; }
|
||||||
|
.fr-cap { font-size: 12px; color: var(--ink-3); }
|
||||||
|
.fr-val { font-size: 26px; font-weight: 800; line-height: 1.2; }
|
||||||
|
.fr-calc { font-size: 12px; color: var(--ink-2); margin-top: 2px; }
|
||||||
|
.fr-right { flex: 1; display: flex; flex-direction: column; gap: 7px; border-left: 1px dashed var(--border-1, #e5e7eb); padding-left: 18px; }
|
||||||
|
.fr-step { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--ink-2); }
|
||||||
|
.sc-hero { display: flex; align-items: center; gap: 14px; padding: 16px 18px; border-radius: 12px; background: var(--bg-2, #f8fafc); border: 1px solid var(--border-1, #e5e7eb); }
|
||||||
|
.sc-hero-left { flex: 1; min-width: 0; }
|
||||||
|
.sc-name { font-size: 16px; font-weight: 800; color: var(--ink); }
|
||||||
|
.sc-sub { font-size: 12px; color: var(--ink-3); margin-top: 4px; }
|
||||||
|
.sc-badge { display: inline-flex; align-items: center; gap: 4px; padding: 6px 12px; border-radius: 999px; font-size: 13px; font-weight: 800; box-shadow: 0 1px 4px rgba(0,0,0,.08); white-space: nowrap; }
|
||||||
|
.sc-scale { position: relative; display: flex; gap: 4px; margin-top: 14px; }
|
||||||
|
.sc-seg { flex: 1; text-align: center; padding: 7px 0 6px; border-radius: 8px; opacity: .5; border: 1px solid transparent; }
|
||||||
|
.sc-seg span { font-size: 12.5px; font-weight: 800; display: block; }
|
||||||
|
.sc-seg i { font-style: normal; font-size: 10.5px; opacity: .8; }
|
||||||
|
.sc-seg.fail { background: linear-gradient(135deg,#fef2f2,#fee2e2); color: #b91c1c; }
|
||||||
|
.sc-seg.bronze { background: linear-gradient(135deg,#ffedd5,#fed7aa); color: #9a3412; }
|
||||||
|
.sc-seg.silver { background: linear-gradient(135deg,#f1f5f9,#e2e8f0); color: #475569; }
|
||||||
|
.sc-seg.gold { background: linear-gradient(135deg,#fef3c7,#fde68a); color: #b45309; }
|
||||||
|
.sc-seg.on { opacity: 1; border-color: currentColor; box-shadow: 0 0 0 1px currentColor inset; }
|
||||||
|
.sc-marker { position: absolute; top: -8px; transform: translateX(-50%); transition: left .3s; }
|
||||||
|
.sc-marker-dot { display: block; width: 14px; height: 14px; border-radius: 50%; background: var(--primary); border: 3px solid #fff; box-shadow: 0 1px 5px rgba(0,0,0,.35); }
|
||||||
|
.sc-marker-tip { position: absolute; top: -24px; left: 50%; transform: translateX(-50%); background: var(--primary); color: #fff; font-size: 11px; font-weight: 700; padding: 2px 7px; border-radius: 5px; white-space: nowrap; }
|
||||||
|
.sc-marker-tip::after { content: ''; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); border: 4px solid transparent; border-top-color: var(--primary); }
|
||||||
|
.sc-scale-cap { margin-top: 6px; font-size: 11px; color: var(--ink-3); }
|
||||||
|
|
||||||
|
.sc-dim-icon { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: 8px; background: var(--primary-soft, #dbeafe); color: var(--primary); font-size: 18px; }
|
||||||
|
.sc-weight-chip { display: inline-block; margin-left: 6px; padding: 1px 7px; font-size: 11px; font-weight: 800; color: var(--primary); background: var(--primary-soft, #dbeafe); border-radius: 4px; vertical-align: 1px; }
|
||||||
|
.sc-op-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.sc-seg2 { display: inline-flex; border: 1px solid var(--border-1, #e5e7eb); border-radius: 10px; overflow: hidden; }
|
||||||
|
.sc-seg2-btn { display: inline-flex; align-items: center; gap: 4px; padding: 8px 18px; font-size: 13px; font-weight: 700; background: var(--bg-1, #fff); color: var(--ink-3); border: none; cursor: pointer; transition: all .15s; }
|
||||||
|
.sc-seg2-btn + .sc-seg2-btn { border-left: 1px solid var(--border-1, #e5e7eb); }
|
||||||
|
.sc-seg2-btn.plus { background: var(--el-color-success); color: #fff; }
|
||||||
|
.sc-seg2-btn.minus { background: var(--el-color-danger); color: #fff; }
|
||||||
|
.sc-arrow-text { font-size: 14px; color: var(--ink-2); }
|
||||||
|
.sc-arrow-text b { font-size: 17px; color: var(--ink); }
|
||||||
|
.sc-lv-change { font-size: 15px; font-weight: 800; }
|
||||||
|
</style>
|
||||||
@ -13,7 +13,7 @@
|
|||||||
* password 密码 (新建必填, 修改可选-留空表示不修改)
|
* password 密码 (新建必填, 修改可选-留空表示不修改)
|
||||||
* sex 性别 0男1女 (选填)
|
* sex 性别 0男1女 (选填)
|
||||||
* enabled 禁用标识 0正常1禁用 (选填, 默认正常)
|
* enabled 禁用标识 0正常1禁用 (选填, 默认正常)
|
||||||
* enterpriseId 关联企业 (单选, 取 tb_user.enterpriseId; 真多对多需后端加 user_enterprise_bind 表, 当前未做)
|
* enterpriseIds 关联企业 (多选, 一人可属多个企业; 后端维护 tb_supplier_enterprise 绑定表, C端可切换企业身份)
|
||||||
*
|
*
|
||||||
* 已去掉:
|
* 已去掉:
|
||||||
* - header (头像 URL): 改用默认头像, 此处无意义
|
* - header (头像 URL): 改用默认头像, 此处无意义
|
||||||
@ -29,11 +29,11 @@ interface UserForm {
|
|||||||
mobile: string
|
mobile: string
|
||||||
password: string
|
password: string
|
||||||
sex: '' | '0' | '1'
|
sex: '' | '0' | '1'
|
||||||
enterpriseId: number | null
|
enterpriseIds: string[]
|
||||||
enabled: '' | '0' | '1'
|
enabled: '' | '0' | '1'
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EnterpriseOpt { id: number; name: string }
|
interface EnterpriseOpt { id: string; name: string }
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
visible: boolean
|
visible: boolean
|
||||||
@ -58,7 +58,7 @@ const form = reactive<UserForm>({
|
|||||||
mobile: '',
|
mobile: '',
|
||||||
password: '',
|
password: '',
|
||||||
sex: '',
|
sex: '',
|
||||||
enterpriseId: null,
|
enterpriseIds: [],
|
||||||
enabled: '0',
|
enabled: '0',
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -88,10 +88,16 @@ watch(
|
|||||||
() => props.visible,
|
() => props.visible,
|
||||||
async (v) => {
|
async (v) => {
|
||||||
if (!v) return
|
if (!v) return
|
||||||
|
// 2026-08-18 修复"先黑一下": loading 提前置 true 覆盖整个加载过程,
|
||||||
|
// 企业下拉与用户详情并行加载, 避免空表单闪现 + 后半程才出现遮罩
|
||||||
|
loading.value = true
|
||||||
resetForm()
|
resetForm()
|
||||||
await loadEnterprises()
|
try {
|
||||||
if (props.userId) {
|
const jobs: Promise<void>[] = [loadEnterprises()]
|
||||||
await loadDetail()
|
if (props.userId) jobs.push(loadDetail())
|
||||||
|
await Promise.all(jobs)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -102,36 +108,33 @@ function resetForm() {
|
|||||||
form.mobile = ''
|
form.mobile = ''
|
||||||
form.password = ''
|
form.password = ''
|
||||||
form.sex = ''
|
form.sex = ''
|
||||||
form.enterpriseId = null
|
form.enterpriseIds = []
|
||||||
form.enabled = '0'
|
form.enabled = '0'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载企业下拉选项 (弹窗打开时拉一次, 后续复用)
|
* 加载企业下拉选项 (弹窗打开时拉一次, 后续复用)
|
||||||
*
|
*
|
||||||
* 实现: 从 /user/user/page 用户列表聚合 (enterpriseId, enterpriseName) 去重
|
* 2026-08-18 改数据源: 平台企业列表 /user/enterprise/page (企业名干净)
|
||||||
* 原因: gateway.json 当前没配 /enterprise/** 路由, /enterprise/list 返 404;
|
* 之前从 /user/user/page 用户表聚合 enterpriseName, 混入"xx等N家企业"脏文本且重复
|
||||||
* 而 user-server jar 内 ApiEnterpriseController 也没正确暴露路径
|
* ID 用字符串 (雪花ID 19位, Number 会丢精度)
|
||||||
* (推测 jar 是旧版本编译, controller 路径没生效)
|
|
||||||
* 替代: 用户表里有 enterpriseId + enterpriseName 字段, 直接聚合即可
|
|
||||||
* 兜底: 后端失败时给空数组
|
|
||||||
*/
|
*/
|
||||||
async function loadEnterprises() {
|
async function loadEnterprises() {
|
||||||
if (enterpriseOptions.value.length > 0) return
|
if (enterpriseOptions.value.length > 0) return
|
||||||
enterpriseLoading.value = true
|
enterpriseLoading.value = true
|
||||||
try {
|
try {
|
||||||
const data: any = await httpGet('/user/user/page', { page: 1, limit: 10000 })
|
const data: any = await httpGet('/user/enterprise/page', { page: 1, limit: 10000 })
|
||||||
const arr = Array.isArray(data?.list) ? data.list : (Array.isArray(data) ? data : [])
|
const arr = Array.isArray(data?.list) ? data.list : []
|
||||||
const seen = new Map<number, string>()
|
const seen = new Map<string, string>()
|
||||||
for (const u of arr) {
|
for (const e of arr) {
|
||||||
const eid = Number(u.enterpriseId ?? 0)
|
const eid = String(e.id ?? '')
|
||||||
if (eid > 0 && !seen.has(eid)) {
|
if (eid && !seen.has(eid)) {
|
||||||
seen.set(eid, u.enterpriseName || `企业 #${eid}`)
|
seen.set(eid, e.enterpriseName || `企业 #${eid}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
enterpriseOptions.value = Array.from(seen.entries())
|
enterpriseOptions.value = Array.from(seen.entries())
|
||||||
.map(([id, name]) => ({ id, name }))
|
.map(([id, name]) => ({ id, name }))
|
||||||
.sort((a, b) => a.id - b.id)
|
.sort((a, b) => (a.id > b.id ? 1 : -1))
|
||||||
} catch {
|
} catch {
|
||||||
enterpriseOptions.value = []
|
enterpriseOptions.value = []
|
||||||
} finally {
|
} finally {
|
||||||
@ -149,7 +152,11 @@ async function loadDetail() {
|
|||||||
form.mobile = data.mobile || ''
|
form.mobile = data.mobile || ''
|
||||||
form.password = ''
|
form.password = ''
|
||||||
form.sex = (data.sex === 0 || data.sex === 1 ? String(data.sex) : '') as UserForm['sex']
|
form.sex = (data.sex === 0 || data.sex === 1 ? String(data.sex) : '') as UserForm['sex']
|
||||||
form.enterpriseId = data.enterpriseId ? Number(data.enterpriseId) : null
|
// 多企业关联回显: 后端 getUserInfo 返回 enterpriseIdList; 兼容旧数据单值 enterpriseId
|
||||||
|
const ids = Array.isArray(data.enterpriseIdList)
|
||||||
|
? data.enterpriseIdList.map((v: any) => String(v)).filter((v: string) => v && v !== '0')
|
||||||
|
: (data.enterpriseId ? [String(data.enterpriseId)] : [])
|
||||||
|
form.enterpriseIds = ids
|
||||||
form.enabled = (data.enabled === 0 || data.enabled === 1 ? String(data.enabled) : '0') as UserForm['enabled']
|
form.enabled = (data.enabled === 0 || data.enabled === 1 ? String(data.enabled) : '0') as UserForm['enabled']
|
||||||
}
|
}
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@ -181,7 +188,7 @@ async function submit() {
|
|||||||
}
|
}
|
||||||
if (form.password) payload.password = form.password
|
if (form.password) payload.password = form.password
|
||||||
if (form.sex !== '') payload.sex = Number(form.sex)
|
if (form.sex !== '') payload.sex = Number(form.sex)
|
||||||
if (form.enterpriseId) payload.enterpriseId = Number(form.enterpriseId)
|
if (form.enterpriseIds.length) payload.enterpriseIds = form.enterpriseIds.map(String)
|
||||||
if (form.enabled !== '') payload.enabled = Number(form.enabled)
|
if (form.enabled !== '') payload.enabled = Number(form.enabled)
|
||||||
|
|
||||||
if (mode.value === 'create') {
|
if (mode.value === 'create') {
|
||||||
@ -213,7 +220,12 @@ async function submit() {
|
|||||||
@update:model-value="(v: boolean) => emit('update:visible', v)"
|
@update:model-value="(v: boolean) => emit('update:visible', v)"
|
||||||
@closed="resetForm"
|
@closed="resetForm"
|
||||||
>
|
>
|
||||||
<div v-loading="loading">
|
<!-- 透明遮罩 + 加载文案: 消除"先黑一下"观感 (暗色主题下黑遮罩叠加更明显) -->
|
||||||
|
<div
|
||||||
|
v-loading="loading"
|
||||||
|
element-loading-text="数据加载中…"
|
||||||
|
element-loading-background="transparent"
|
||||||
|
>
|
||||||
<el-form
|
<el-form
|
||||||
ref="formRef"
|
ref="formRef"
|
||||||
:model="form"
|
:model="form"
|
||||||
@ -248,12 +260,13 @@ async function submit() {
|
|||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="关联企业">
|
<el-form-item label="关联企业" prop="enterpriseIds">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="form.enterpriseId"
|
v-model="form.enterpriseIds"
|
||||||
placeholder="请选择关联企业 (可选)"
|
placeholder="从平台企业列表选择 (可多选)"
|
||||||
clearable
|
multiple
|
||||||
filterable
|
filterable
|
||||||
|
clearable
|
||||||
:loading="enterpriseLoading"
|
:loading="enterpriseLoading"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,13 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* F006 用户管理 (前台用户列表) - W17 设计规范 v2 完整版
|
* F006 用户管理 (前台用户列表) - 2026-08-18 v2 简化版
|
||||||
*
|
*
|
||||||
* 按 ui-preview-v2.html 模板: 9 列 + 6 搜索 + 4 操作 + 状态 tab
|
* 用户反馈: 列表内容简化 - 单行显示、无头像、字段保持原样 (仅 UI 美化)
|
||||||
|
* 字段: 用户昵称 / 手机号 / 是否企业认证 / 关联企业 / 平台账户余额 / 禁用标识 / 注册时间 / 操作 (用户ID 已按需求隐藏)
|
||||||
|
* 后端端点: GET /user/user/page / GET /user/user/export / POST /user/user/update / POST /user/user/delete
|
||||||
|
* 保留功能: tab 全部/正常/已禁用(stats 与 tab 解耦) / 搜索 / 导出 / 新建编辑(UserFormDialog) / 批量操作
|
||||||
*/
|
*/
|
||||||
import { onMounted, ref, reactive, computed } from 'vue'
|
import { onMounted, ref, reactive, computed } from 'vue'
|
||||||
import { http, httpGet, httpPost } from '@/utils/request'
|
import { http, httpGet, httpPost } from '@/utils/request'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Plus } from '@element-plus/icons-vue'
|
import { Plus, Phone, Clock } from '@element-plus/icons-vue'
|
||||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||||
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||||
import SearchBar from '@/components/SearchBar/SearchBar.vue'
|
import SearchBar from '@/components/SearchBar/SearchBar.vue'
|
||||||
@ -35,6 +38,8 @@ const total = ref(0)
|
|||||||
const enabledStats = reactive({ normal: 0, disabled: 0 })
|
const enabledStats = reactive({ normal: 0, disabled: 0 })
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const activeTab = ref('all')
|
const activeTab = ref('all')
|
||||||
|
const selectedIds = ref<string[]>([])
|
||||||
|
const selectedCount = ref(0)
|
||||||
|
|
||||||
// 表单弹窗状态
|
// 表单弹窗状态
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
@ -44,7 +49,6 @@ const exporting = ref(false)
|
|||||||
const query = reactive({
|
const query = reactive({
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
userId: '',
|
|
||||||
nickName: '',
|
nickName: '',
|
||||||
mobile: '',
|
mobile: '',
|
||||||
enterpriseName: '',
|
enterpriseName: '',
|
||||||
@ -52,49 +56,32 @@ const query = reactive({
|
|||||||
enabled: '' as '' | '0' | '1'
|
enabled: '' as '' | '0' | '1'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 状态/认证胶囊色 (引用全局 --st-* 变量, 主题切换自动适配暗色)
|
||||||
|
const ENABLED_META: Record<number, { label: string; color: string; bg: string }> = {
|
||||||
|
0: { label: '正常', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
|
||||||
|
1: { label: '禁用', color: 'var(--st-err-fg)', bg: 'var(--st-err-bg)' }
|
||||||
|
}
|
||||||
|
const CERT_META: Record<number, { label: string; color: string; bg: string }> = {
|
||||||
|
1: { label: '是', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
|
||||||
|
0: { label: '否', color: 'var(--st-lv4-fg)', bg: 'var(--st-lv4-bg)' }
|
||||||
|
}
|
||||||
|
|
||||||
const columns: any[] = [
|
const columns: any[] = [
|
||||||
{ type: 'selection', width: 50, fixed: 'left', align: 'center' },
|
{ type: 'selection', width: 48, fixed: 'left', align: 'center' },
|
||||||
{ prop: 'id', label: '用户ID', width: 100, align: 'center' },
|
{ prop: 'nickName', label: '用户昵称', minWidth: 200, slot: 'nickName' },
|
||||||
{ prop: 'nickName', label: '用户昵称', minWidth: 160, align: 'center' },
|
{ prop: 'mobile', label: '手机号', width: 140, slot: 'mobile' },
|
||||||
{ prop: 'mobile', label: '手机号', width: 140, align: 'center' },
|
{ prop: 'isEnterprise', label: '是否企业认证', width: 120, slot: 'cert', align: 'center' },
|
||||||
{
|
{ prop: 'enterpriseName', label: '关联企业', minWidth: 180, slot: 'enterpriseName' },
|
||||||
prop: 'isEnterprise',
|
{ prop: 'accountBalance', label: '平台账户余额', width: 150, slot: 'balance', align: 'right' },
|
||||||
label: '是否企业认证',
|
{ prop: 'enabled', label: '禁用标识', width: 100, slot: 'enabled', align: 'center' },
|
||||||
width: 140,
|
{ label: '注册时间', width: 165, slot: 'createDate' },
|
||||||
align: 'center',
|
{ label: '操作', width: 170, slot: 'action', fixed: 'right', align: 'center' }
|
||||||
formatter: (row: FrontUser) => row.isEnterprise === 1 ? '是' : '否'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prop: 'enterpriseName',
|
|
||||||
label: '关联企业',
|
|
||||||
minWidth: 200,
|
|
||||||
align: 'center',
|
|
||||||
formatter: (row: FrontUser) => row.enterpriseName || '--'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prop: 'accountBalance',
|
|
||||||
label: '平台账户余额',
|
|
||||||
width: 140,
|
|
||||||
align: 'right',
|
|
||||||
formatter: (row: FrontUser) => Number(row.accountBalance || 0).toFixed(2)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prop: 'enabled',
|
|
||||||
label: '禁用标识',
|
|
||||||
width: 110,
|
|
||||||
align: 'center',
|
|
||||||
slot: 'enabled'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prop: 'createDate',
|
|
||||||
label: '注册时间',
|
|
||||||
width: 180,
|
|
||||||
align: 'center',
|
|
||||||
formatter: (row: FrontUser) => fmtDate(row.createDate)
|
|
||||||
},
|
|
||||||
{ label: '操作', width: 140, slot: 'action', fixed: 'right', align: 'center' }
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function fmtMoney(n: number): string {
|
||||||
|
const v = Number(n || 0)
|
||||||
|
return `¥ ${v.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||||
|
}
|
||||||
function fmtDate(ts: number | null | undefined) {
|
function fmtDate(ts: number | null | undefined) {
|
||||||
if (!ts) return '--'
|
if (!ts) return '--'
|
||||||
const d = new Date(Number(ts))
|
const d = new Date(Number(ts))
|
||||||
@ -104,12 +91,10 @@ function fmtDate(ts: number | null | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建除 enabled (tab) 外的搜索参数 (用于 stats 查询)
|
* 构建除 enabled (tab) 外的搜索参数 (用于 stats 查询) — stats 与 tab 解耦
|
||||||
* stats 必须与 tab 解耦, 只反映当前搜索条件下的全量计数
|
|
||||||
*/
|
*/
|
||||||
function buildSearchParams() {
|
function buildSearchParams() {
|
||||||
const params: any = {}
|
const params: any = {}
|
||||||
if (query.userId) params.id = query.userId
|
|
||||||
if (query.nickName) params.nickName = query.nickName
|
if (query.nickName) params.nickName = query.nickName
|
||||||
if (query.mobile) params.mobile = query.mobile
|
if (query.mobile) params.mobile = query.mobile
|
||||||
if (query.enterpriseName) params.enterpriseName = query.enterpriseName
|
if (query.enterpriseName) params.enterpriseName = query.enterpriseName
|
||||||
@ -152,11 +137,7 @@ async function loadList() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 加载 stats (normal/disabled 全量计数) — 与 tab 无关 */
|
||||||
* 加载 stats (normal/disabled 全量计数)
|
|
||||||
* 与 tab 无关, 只跟搜索条件 (userId/nickName/mobile/isEnterprise/enterpriseName) 关联
|
|
||||||
* 通过 limit=1 + page=1 拿到 total (后端只 count 不返列表, 开销可忽略)
|
|
||||||
*/
|
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
try {
|
try {
|
||||||
const baseParams = buildSearchParams()
|
const baseParams = buildSearchParams()
|
||||||
@ -177,7 +158,6 @@ async function load() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabItems = computed(() => [
|
const tabItems = computed(() => [
|
||||||
// 全部 = 正常 + 已禁用 (跨 tab 一致, 与搜索条件关联)
|
|
||||||
{ key: 'all', label: '全部', count: enabledStats.normal + enabledStats.disabled },
|
{ key: 'all', label: '全部', count: enabledStats.normal + enabledStats.disabled },
|
||||||
{ key: 'normal', label: '正常', count: enabledStats.normal },
|
{ key: 'normal', label: '正常', count: enabledStats.normal },
|
||||||
{ key: 'disabled', label: '已禁用', count: enabledStats.disabled }
|
{ key: 'disabled', label: '已禁用', count: enabledStats.disabled }
|
||||||
@ -196,13 +176,15 @@ function onPageChange(p: number) { query.page = p; load() }
|
|||||||
function onLimitChange(l: number) { query.limit = l; query.page = 1; load() }
|
function onLimitChange(l: number) { query.limit = l; query.page = 1; load() }
|
||||||
function onSearch() { query.page = 1; load() }
|
function onSearch() { query.page = 1; load() }
|
||||||
function onReset() {
|
function onReset() {
|
||||||
query.userId = ''; query.nickName = ''; query.mobile = ''
|
query.nickName = ''; query.mobile = ''
|
||||||
query.enterpriseName = ''; query.isEnterprise = ''; query.enabled = ''
|
query.enterpriseName = ''; query.isEnterprise = ''; query.enabled = ''
|
||||||
activeTab.value = 'all'
|
activeTab.value = 'all'
|
||||||
query.page = 1
|
query.page = 1
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onSelectionChange(rows: any[]) { selectedIds.value = rows.map((r: any) => r.id); selectedCount.value = rows.length }
|
||||||
|
|
||||||
function onEdit(row: FrontUser) {
|
function onEdit(row: FrontUser) {
|
||||||
editingUserId.value = row.id
|
editingUserId.value = row.id
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
@ -217,33 +199,20 @@ function onDialogSuccess() {
|
|||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 导出当前搜索条件下的用户列表为 Excel */
|
||||||
* 导出当前搜索条件下的用户列表为 Excel
|
|
||||||
* 后端: GET /user/user/export (返 Excel 文件流, Content-Type: application/vnd.ms-excel)
|
|
||||||
* 走 http 实例 (自动带 Bearer token), responseType: 'blob' 接二进制流
|
|
||||||
*/
|
|
||||||
async function exportExcel() {
|
async function exportExcel() {
|
||||||
exporting.value = true
|
exporting.value = true
|
||||||
try {
|
try {
|
||||||
const params = buildSearchParams()
|
const params = buildSearchParams()
|
||||||
if (query.enabled !== '') params.enabled = Number(query.enabled)
|
if (query.enabled !== '') params.enabled = Number(query.enabled)
|
||||||
const resp = await http.get<Blob>('/user/user/export', {
|
const resp = await http.get<Blob>('/user/user/export', { params, responseType: 'blob' })
|
||||||
params,
|
|
||||||
responseType: 'blob'
|
|
||||||
})
|
|
||||||
const blob = resp.data
|
const blob = resp.data
|
||||||
if (!blob || blob.size === 0) {
|
if (!blob || blob.size === 0) { ElMessage.error('导出内容为空'); return }
|
||||||
ElMessage.error('导出内容为空')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 优先从响应头取文件名 (后端 ExcelUtils 会设置)
|
|
||||||
const dispo = resp.headers?.['content-disposition'] || resp.headers?.['Content-Disposition']
|
const dispo = resp.headers?.['content-disposition'] || resp.headers?.['Content-Disposition']
|
||||||
let filename = `用户列表_${new Date().toISOString().slice(0, 10)}.xls`
|
let filename = `用户列表_${new Date().toISOString().slice(0, 10)}.xls`
|
||||||
if (typeof dispo === 'string') {
|
if (typeof dispo === 'string') {
|
||||||
const m = dispo.match(/filename\*?=(?:UTF-8\u0027\u0027)?["']?([^;"']+)["']?/i)
|
const m = dispo.match(/filename\*?=(?:UTF-8\u0027\u0027)?["']?([^;"']+)["']?/i)
|
||||||
if (m && m[1]) {
|
if (m && m[1]) { try { filename = decodeURIComponent(m[1]) } catch { filename = m[1] } }
|
||||||
try { filename = decodeURIComponent(m[1]) } catch { filename = m[1] }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const url = URL.createObjectURL(new Blob([blob]))
|
const url = URL.createObjectURL(new Blob([blob]))
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
@ -255,40 +224,59 @@ async function exportExcel() {
|
|||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
ElMessage.success(`已导出 ${filename}`)
|
ElMessage.success(`已导出 ${filename}`)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const msg = e?.msg || e?.message || '导出失败'
|
ElMessage.error(e?.msg || e?.message || '导出失败')
|
||||||
ElMessage.error(msg)
|
|
||||||
} finally {
|
} finally {
|
||||||
exporting.value = false
|
exporting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 批量操作 ==========
|
||||||
|
async function batchToggle(next: number) {
|
||||||
|
const action = next === 1 ? '禁用' : '启用'
|
||||||
|
if (!selectedIds.value.length) { ElMessage.warning('请先选择用户'); return }
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认${action}已选 ${selectedIds.value.length} 个用户?`, `批量${action}确认`, { type: 'warning' })
|
||||||
|
} catch { return }
|
||||||
|
let ok = 0
|
||||||
|
for (const id of selectedIds.value) {
|
||||||
|
try { await httpPost('/user/user/update', { id, enabled: next }); ok++ } catch {/* 单条跳过 */}
|
||||||
|
}
|
||||||
|
ElMessage.success(`已${action} ${ok} 个用户`)
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
async function batchDelete() {
|
||||||
|
if (!selectedIds.value.length) { ElMessage.warning('请先选择用户'); return }
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认删除已选 ${selectedIds.value.length} 个用户?该操作不可恢复`, '批量删除确认', { type: 'warning' })
|
||||||
|
} catch { return }
|
||||||
|
// 后端 delete 接口签名是 (@RequestBody Long[] ids), 一次传数组 (避免 N 次循环)
|
||||||
|
try {
|
||||||
|
await httpPost('/user/user/delete', selectedIds.value)
|
||||||
|
ElMessage.success(`已删除 ${selectedIds.value.length} 个用户`)
|
||||||
|
selectedIds.value = []
|
||||||
|
load()
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '批量删除失败') }
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleEnabled(row: FrontUser) {
|
async function toggleEnabled(row: FrontUser) {
|
||||||
// 当前 enabled: 0=正常 1=禁用; 要切换到对端
|
|
||||||
const next = row.enabled === 0 ? 1 : 0
|
const next = row.enabled === 0 ? 1 : 0
|
||||||
const action = next === 1 ? '禁用' : '启用'
|
const action = next === 1 ? '禁用' : '启用'
|
||||||
try {
|
try { await ElMessageBox.confirm(`确认${action}用户「${row.nickName}」?`, `${action}确认`, { type: 'warning' }) } catch { return }
|
||||||
await ElMessageBox.confirm(`确认${action}用户「${row.nickName}」?`, `${action}确认`, { type: 'warning' })
|
|
||||||
} catch { return }
|
|
||||||
try {
|
try {
|
||||||
await httpPost('/user/user/update', { id: row.id, enabled: next })
|
await httpPost('/user/user/update', { id: row.id, enabled: next })
|
||||||
ElMessage.success(`${action}成功`)
|
ElMessage.success(`${action}成功`)
|
||||||
row.enabled = next
|
row.enabled = next
|
||||||
} catch (e: any) {
|
} catch (e: any) { ElMessage.error(e?.msg || e?.message || `${action}失败`) }
|
||||||
ElMessage.error(e?.msg || e?.message || `${action}失败`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteUser(row: FrontUser) {
|
async function deleteUser(row: FrontUser) {
|
||||||
|
try { await ElMessageBox.confirm(`确认删除用户「${row.nickName}」?该操作不可恢复`, '删除确认', { type: 'warning' }) } catch { return }
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(`确认删除用户「${row.nickName}」?该操作不可恢复`, '删除确认', { type: 'warning' })
|
// 后端 delete 接口签名是 (@RequestBody Long[] ids), 需要传数组
|
||||||
} catch { return }
|
await httpPost('/user/user/delete', [row.id])
|
||||||
try {
|
|
||||||
await httpPost('/user/user/delete', { id: row.id })
|
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success('删除成功')
|
||||||
load()
|
load()
|
||||||
} catch (e: any) {
|
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '删除失败') }
|
||||||
ElMessage.error(e?.msg || e?.message || '删除失败')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
@ -296,25 +284,20 @@ onMounted(load)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="rr-page">
|
<div class="rr-page">
|
||||||
<PageHeader title="用户列表" :sub="`管理前台注册用户,共 ${enabledStats.normal + enabledStats.disabled} 个账号`">
|
<PageHeader title="用户列表" :sub="`共 ${enabledStats.normal + enabledStats.disabled} 个注册账号`">
|
||||||
<el-button @click="exportExcel" :loading="exporting">导出</el-button>
|
<el-button @click="exportExcel" :loading="exporting">导出</el-button>
|
||||||
<el-button type="primary" @click="onCreate">
|
<el-button type="primary" @click="onCreate">
|
||||||
<el-icon style="margin-right: 4px; vertical-align: -2px;"><Plus /></el-icon>
|
<el-icon style="margin-right: 4px; vertical-align: -2px;"><Plus /></el-icon>
|
||||||
新建用户
|
新建用户
|
||||||
</el-button>
|
</el-button>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<UserFormDialog
|
<UserFormDialog v-model:visible="dialogVisible" :user-id="editingUserId" @success="onDialogSuccess" />
|
||||||
v-model:visible="dialogVisible"
|
|
||||||
:user-id="editingUserId"
|
|
||||||
@success="onDialogSuccess"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ListTabs v-model="activeTab" :items="tabItems" @change="onTabChange" />
|
<ListTabs v-model="activeTab" :items="tabItems" @change="onTabChange" />
|
||||||
|
|
||||||
<SearchBar
|
<SearchBar
|
||||||
v-model:filters="query"
|
v-model:filters="query"
|
||||||
:fields="[
|
:fields="[
|
||||||
{ key: 'userId', label: '用户ID', type: 'text', placeholder: '用户ID' },
|
|
||||||
{ key: 'nickName', label: '用户昵称', type: 'text', placeholder: '用户昵称' },
|
{ key: 'nickName', label: '用户昵称', type: 'text', placeholder: '用户昵称' },
|
||||||
{ key: 'mobile', label: '手机号', type: 'text', placeholder: '手机号' },
|
{ key: 'mobile', label: '手机号', type: 'text', placeholder: '手机号' },
|
||||||
{ key: 'isEnterprise', label: '是否企业认证', type: 'select', placeholder: '所有状态', width: '160px',
|
{ key: 'isEnterprise', label: '是否企业认证', type: 'select', placeholder: '所有状态', width: '160px',
|
||||||
@ -325,39 +308,128 @@ onMounted(load)
|
|||||||
@reset="onReset"
|
@reset="onReset"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DataTable
|
<div class="rr-list-card">
|
||||||
:columns="columns"
|
<!-- 批量操作栏 -->
|
||||||
:data="list"
|
<div class="batch-bar" :class="{ 'has-selection': selectedCount > 0 }">
|
||||||
:loading="loading"
|
<span v-if="selectedCount > 0" class="batch-count">
|
||||||
:total="total"
|
已选 <strong>{{ selectedCount }}</strong> 个用户
|
||||||
:page="query.page"
|
</span>
|
||||||
:limit="query.limit"
|
<span v-else class="batch-hint">勾选用户后可批量操作</span>
|
||||||
:page-sizes="[10, 20, 50, 100]"
|
<span class="batch-actions">
|
||||||
:show-pagination="true"
|
<el-button size="small" type="warning" plain :disabled="!selectedCount" @click="batchToggle(1)">批量禁用</el-button>
|
||||||
:borderless="true"
|
<el-button size="small" type="success" plain :disabled="!selectedCount" @click="batchToggle(0)">批量启用</el-button>
|
||||||
row-key="id"
|
<el-button size="small" type="danger" :disabled="!selectedCount" @click="batchDelete">批量删除</el-button>
|
||||||
empty-text="暂无用户"
|
</span>
|
||||||
@page-change="onPageChange"
|
</div>
|
||||||
@limit-change="onLimitChange"
|
|
||||||
>
|
<DataTable
|
||||||
<template #enabled="{ row }">
|
:columns="columns"
|
||||||
<el-tag v-if="row.enabled === 0" type="success" size="small" effect="light" round>正常</el-tag>
|
:data="list"
|
||||||
<el-tag v-else type="danger" size="small" effect="light" round>禁用</el-tag>
|
:loading="loading"
|
||||||
</template>
|
:total="total"
|
||||||
<template #action="{ row }">
|
:page="query.page"
|
||||||
<TableActions
|
:limit="query.limit"
|
||||||
:row="row"
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
:actions="[
|
:show-pagination="true"
|
||||||
{ label: '修改', type: 'primary', onClick: onEdit },
|
:borderless="true"
|
||||||
{ label: (r: any) => r.enabled === 0 ? '禁用' : '启用', type: (r: any) => r.enabled === 0 ? 'warning' : 'success', onClick: toggleEnabled },
|
row-key="id"
|
||||||
{ label: '删除', type: 'danger', onClick: deleteUser }
|
empty-text="暂无用户"
|
||||||
]"
|
@page-change="onPageChange"
|
||||||
/>
|
@limit-change="onLimitChange"
|
||||||
</template>
|
@selection-change="onSelectionChange"
|
||||||
</DataTable>
|
>
|
||||||
|
<!-- 用户昵称: 单行 -->
|
||||||
|
<template #nickName="{ row }">
|
||||||
|
<span class="nick-cell">{{ row.nickName }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 手机号 -->
|
||||||
|
<template #mobile="{ row }">
|
||||||
|
<span class="phone-cell"><el-icon><Phone /></el-icon>{{ row.mobile }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 是否企业认证: 胶囊 -->
|
||||||
|
<template #cert="{ row }">
|
||||||
|
<span class="chip" :style="{ color: CERT_META[row.isEnterprise].color, background: CERT_META[row.isEnterprise].bg }">
|
||||||
|
{{ CERT_META[row.isEnterprise].label }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 关联企业: 单行 -->
|
||||||
|
<template #enterpriseName="{ row }">
|
||||||
|
<span class="text-ellipsis">{{ row.enterpriseName || '--' }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 平台余额: 货币化 -->
|
||||||
|
<template #balance="{ row }">
|
||||||
|
<span class="balance-cell">{{ fmtMoney(row.accountBalance) }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 禁用标识: 圆点胶囊 -->
|
||||||
|
<template #enabled="{ row }">
|
||||||
|
<span class="status-tag" :style="{ color: ENABLED_META[row.enabled].color, background: ENABLED_META[row.enabled].bg }">
|
||||||
|
<span class="status-dot"></span>{{ ENABLED_META[row.enabled].label }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 注册时间 -->
|
||||||
|
<template #createDate="{ row }">
|
||||||
|
<span class="date-cell"><el-icon><Clock /></el-icon>{{ fmtDate(row.createDate) }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 操作 -->
|
||||||
|
<template #action="{ row }">
|
||||||
|
<TableActions
|
||||||
|
:row="row"
|
||||||
|
:actions="[
|
||||||
|
{ label: '修改', type: 'primary', onClick: onEdit },
|
||||||
|
{ label: (r: any) => r.enabled === 0 ? '禁用' : '启用', type: (r: any) => r.enabled === 0 ? 'warning' : 'success', onClick: toggleEnabled },
|
||||||
|
{ label: '删除', type: 'danger', onClick: deleteUser }
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.rr-page { max-width: 100%; }
|
.rr-page { max-width: 100%; padding: 16px; }
|
||||||
|
.rr-list-card {
|
||||||
|
background: var(--surface); border-radius: 12px; overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 批量操作栏 ===== */
|
||||||
|
.batch-bar {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 12px 16px; border-bottom: 1px solid var(--border);
|
||||||
|
transition: background .2s;
|
||||||
|
}
|
||||||
|
.batch-bar.has-selection { background: var(--primary-soft, #e0ecff); }
|
||||||
|
.batch-count { font-size: 13px; color: var(--ink-2); }
|
||||||
|
.batch-count strong { color: var(--primary); font-size: 15px; }
|
||||||
|
.batch-hint { font-size: 12px; color: var(--ink-3); }
|
||||||
|
.batch-actions { margin-left: auto; display: flex; gap: 4px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* ===== 单元格 (单行) ===== */
|
||||||
|
.nick-cell { font-size: 14px; font-weight: 600; color: var(--ink); }
|
||||||
|
.text-ellipsis {
|
||||||
|
display: inline-block; max-width: 100%;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
vertical-align: middle; font-size: 13px; color: var(--ink);
|
||||||
|
}
|
||||||
|
.phone-cell { display: inline-flex; align-items: center; gap: 5px; font-size: 13px; color: var(--ink); font-family: 'SF Mono', Menlo, Consolas, monospace; }
|
||||||
|
.date-cell { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--ink-2); font-family: 'SF Mono', Menlo, Consolas, monospace; }
|
||||||
|
.balance-cell { font-size: 13px; font-weight: 600; color: var(--ink); font-family: 'SF Mono', Menlo, Consolas, monospace; }
|
||||||
|
|
||||||
|
/* ===== 胶囊 (全局 --st-* 变量暗色自适应) ===== */
|
||||||
|
.chip {
|
||||||
|
display: inline-flex; align-items: center; width: fit-content;
|
||||||
|
font-size: 12px; font-weight: 500; padding: 2px 12px; border-radius: 20px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.status-tag {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
font-size: 12px; font-weight: 500; padding: 3px 12px; border-radius: 20px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||||
</style>
|
</style>
|
||||||
@ -1,38 +1,87 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* F006 企业详情页 - 独立页面
|
* F006 企业详情页 - 专业 UI 重构版 (2026-08-18)
|
||||||
*
|
*
|
||||||
* 参考老 vue user/enterprise/enterprise-detail.vue 设计 + 新系统主题规范
|
* 参考老 vue user/enterprise/enterprise-detail.vue 设计 + 新系统主题规范
|
||||||
* 数据源:
|
* 数据源:
|
||||||
* - GET /user/enterprise/getInfo?enterpriseId= 企业全量信息(80+字段)
|
* - GET /user/enterprise/getInfo?enterpriseId= 企业全量信息(80+字段, 含聚合评分/商品数/入驻时长)
|
||||||
* - GET /user/performancescorerecord/page?enterpriseId= 履约评分记录
|
* - GET /user/performancescorerecord/page?enterpriseId= 履约评分记录
|
||||||
* 3 Tab: 基本信息 / 资质认证 / 履约情况
|
* - GET /user/enterprise/checkStatus?id=&status=&failReason= 审核(2通过/3驳回, 支持批量逗号)
|
||||||
|
* - POST /user/enterprise/enabled?id= 禁用/启用切换
|
||||||
|
* - POST /user/enterprise/showEnterprise?id= 显示/隐藏切换
|
||||||
|
* - GET /user/enterprise/homeShow?id=&homeShow= 首页推荐切换
|
||||||
|
*
|
||||||
|
* 结构: 状态横幅(审核操作) → 头部信息卡 → 认证流程时间线 → 3Tab(基本信息/资质认证/履约情况)
|
||||||
|
* 审核留痕: 后端无审核历史表, 时间线展示「入驻提交时间(真实)+当前审核结果(真实)
|
||||||
|
* +本会话审核操作(前端本地记录, 不伪造历史)」
|
||||||
*/
|
*/
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { Shop } from '@element-plus/icons-vue'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { httpGet } from '@/utils/request'
|
import {
|
||||||
|
Shop, OfficeBuilding, MapLocation, User, Coin,
|
||||||
|
WarningFilled, SuccessFilled, CircleCloseFilled, Clock, Medal, Star
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
import { httpGet, httpPost } from '@/utils/request'
|
||||||
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const userStore = useUserStore()
|
||||||
const enterpriseId = computed(() => String(route.params.id || ''))
|
const enterpriseId = computed(() => String(route.params.id || ''))
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const activeTab = ref('basic')
|
const activeTab = ref('basic')
|
||||||
const detail = ref<any>({})
|
const detail = ref<any>({})
|
||||||
|
function isPdfUrl(u: string) { return (u || '').toLowerCase().endsWith('.pdf') }
|
||||||
const otherList = ref<any[]>([])
|
const otherList = ref<any[]>([])
|
||||||
const performanceList = ref<any[]>([])
|
const performanceList = ref<any[]>([])
|
||||||
|
|
||||||
const IDENTITY_MAP: Record<number, string> = { 1: '供应商', 2: '生产厂商', 3: '渠道商', 4: '服务商' }
|
// ================= 状态/映射常量 (与列表页统一 5 级) =================
|
||||||
const STATUS_MAP: Record<number, { label: string; type: 'success' | 'warning' | 'danger' | 'info' }> = {
|
const STATUS_META: Record<number, { label: string; type: 'warning' | 'success' | 'danger'; icon: any }> = {
|
||||||
1: { label: '待审核', type: 'warning' },
|
1: { label: '待审核', type: 'warning', icon: WarningFilled },
|
||||||
2: { label: '已认证', type: 'success' },
|
2: { label: '已认证', type: 'success', icon: SuccessFilled },
|
||||||
3: { label: '已驳回', type: 'danger' }
|
3: { label: '已驳回', type: 'danger', icon: CircleCloseFilled }
|
||||||
|
}
|
||||||
|
const IDENTITY_MAP: Record<number, string> = { 1: '供应商', 2: '生产厂商', 3: '渠道商', 4: '服务商' }
|
||||||
|
// 企业类型 (主体类型): 1企业 2个体工商户 —— 与认证表单主体类型选择对应
|
||||||
|
const TYPE_MAP: Record<number, string> = { 1: '企业', 2: '个体工商户' }
|
||||||
|
// 平台履约等级(新体系): >=80金 / [70,80)银 / <70铜, 初始60
|
||||||
|
const LEVEL_MAP: Record<number, string> = { 3: '金牌供应商', 2: '银牌供应商', 1: '铜牌供应商' }
|
||||||
|
function levelOf(score: any): number {
|
||||||
|
const n = Number(score ?? 60)
|
||||||
|
return n >= 80 ? 3 : n >= 70 ? 2 : 1
|
||||||
}
|
}
|
||||||
const LEVEL_MAP: Record<number, string> = { 1: 'A级', 2: 'B级', 3: 'C级', 4: 'D级' }
|
|
||||||
|
|
||||||
const isCertified = computed(() => detail.value.status === 2)
|
const status = computed<number>(() => Number(detail.value.status ?? 0))
|
||||||
|
const isCertified = computed(() => status.value === 2)
|
||||||
|
const isPending = computed(() => status.value === 1)
|
||||||
|
const isRejected = computed(() => status.value === 3)
|
||||||
|
const statusMeta = computed(() => STATUS_META[status.value])
|
||||||
|
// 状态 → banner 修饰类 (CSS 里 banner-warning/success/danger 对应 warning/success/danger)
|
||||||
|
const bannerClass = computed(() => `banner-${statusMeta.value?.type || 'unknown'}`)
|
||||||
|
|
||||||
|
// ===== 变更信息对比 (change_log: [{field, old, new}]) =====
|
||||||
|
const auditChanges = computed(() => {
|
||||||
|
const raw = detail.value?.changeLog
|
||||||
|
if (!raw) return []
|
||||||
|
try {
|
||||||
|
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw
|
||||||
|
if (!Array.isArray(parsed)) return []
|
||||||
|
const list = parsed.filter((c: any) => c && c.field)
|
||||||
|
// 注册首审单的 changeLog 特征 = 全部条目 old 为空(空->填充值), 无对比意义, 隐藏 (2026-10-19)
|
||||||
|
if (list.length && list.every((c: any) => !String(c.old || '').trim())) return []
|
||||||
|
return list
|
||||||
|
} catch { return [] }
|
||||||
|
})
|
||||||
|
const isImageVal = (val: any) => {
|
||||||
|
if (!val) return false
|
||||||
|
const segs = String(val).split(' / ').map((x: string) => x.trim()).filter(Boolean)
|
||||||
|
return segs.length > 0 && segs.every((x: string) => /^https?:\/\//.test(x))
|
||||||
|
}
|
||||||
|
const splitImgs = (val: any) => String(val || '').split(' / ').map((x: string) => x.trim()).filter(Boolean)
|
||||||
|
|
||||||
const logoText = computed(() => (detail.value.enterpriseName || '企').trim().charAt(0))
|
const logoText = computed(() => (detail.value.enterpriseName || '企').trim().charAt(0))
|
||||||
const registerYear = computed(() => {
|
const registerYear = computed(() => {
|
||||||
const t = detail.value.registerTime
|
const t = detail.value.registerTime
|
||||||
@ -49,7 +98,42 @@ const productList = computed<string[]>(() => {
|
|||||||
if (!raw) return []
|
if (!raw) return []
|
||||||
return String(raw).split(/[,,、;;\s]+/).map((s: string) => s.trim()).filter(Boolean)
|
return String(raw).split(/[,,、;;\s]+/).map((s: string) => s.trim()).filter(Boolean)
|
||||||
})
|
})
|
||||||
|
const addressLine = computed(() => [detail.value.provinceName, detail.value.cityName, detail.value.areaName].filter(Boolean).join(' '))
|
||||||
|
const tenure = computed(() => {
|
||||||
|
const y = detail.value.year, m = detail.value.month, d = detail.value.day
|
||||||
|
if (y === undefined && m === undefined && d === undefined) return ''
|
||||||
|
const parts: string[] = []
|
||||||
|
if (y) parts.push(`${y}年`); if (m) parts.push(`${m}个月`); if (d) parts.push(`${d}天`)
|
||||||
|
return parts.join(' ') || '--'
|
||||||
|
})
|
||||||
|
|
||||||
|
// ================= 本会话审核操作记录 (前端本地留痕, 后端无历史表) =================
|
||||||
|
const localAuditLogs = ref<{ time: string; operator: string; result: string; reason: string }[]>([])
|
||||||
|
const auditVisible = ref(false)
|
||||||
|
const auditBusy = ref(false)
|
||||||
|
const auditForm = reactive({ result: 2, failReason: '' })
|
||||||
|
|
||||||
|
const timelineItems = computed(() => {
|
||||||
|
const items: { kind: string; title: string; time: string; desc: string; operator?: string }[] = []
|
||||||
|
// ① 提交认证 (真实数据)
|
||||||
|
items.push({ kind: 'submit', title: '提交企业认证', time: fmtDate(detail.value.createDate), desc: '企业提交了企业认证申请资料' })
|
||||||
|
// ② 当前审核结果 (真实数据)
|
||||||
|
if (status.value === 1) items.push({ kind: 'pending', title: '等待审核', time: '进行中', desc: '运营人员正在审核,请耐心等待' })
|
||||||
|
else if (status.value === 2) items.push({ kind: 'pass', title: '审核通过', time: '已认证', desc: '企业认证通过,可正常开展业务' })
|
||||||
|
else if (status.value === 3) items.push({ kind: 'fail', title: '审核驳回', time: '未通过', desc: detail.value.failReason || '未填写驳回原因' })
|
||||||
|
// ③ 本会话内完成的审核操作 (本地追加)
|
||||||
|
for (const log of localAuditLogs.value) {
|
||||||
|
items.push({
|
||||||
|
kind: log.result.includes('通过') ? 'pass' : 'fail',
|
||||||
|
title: log.result, time: log.time,
|
||||||
|
desc: log.reason || '—',
|
||||||
|
operator: log.operator
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
// ================= 数据加载 =================
|
||||||
function maskMobile(m: string | number | null | undefined): string {
|
function maskMobile(m: string | number | null | undefined): string {
|
||||||
if (m === undefined || m === null || m === '') return '--'
|
if (m === undefined || m === null || m === '') return '--'
|
||||||
const s = String(m)
|
const s = String(m)
|
||||||
@ -63,6 +147,10 @@ function fmtMoney(n: any): string {
|
|||||||
const v = Number(n || 0)
|
const v = Number(n || 0)
|
||||||
return v ? `¥ ${v.toLocaleString()}` : '¥ 0'
|
return v ? `¥ ${v.toLocaleString()}` : '¥ 0'
|
||||||
}
|
}
|
||||||
|
function fmtNum(n: any): string {
|
||||||
|
const v = Number(n)
|
||||||
|
return Number.isFinite(v) ? String(v) : '--'
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@ -87,10 +175,76 @@ async function load() {
|
|||||||
} catch { performanceList.value = [] }
|
} catch { performanceList.value = [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================= 审核交互 =================
|
||||||
|
function openAudit(defaultResult: number = 2) {
|
||||||
|
auditForm.result = defaultResult
|
||||||
|
auditForm.failReason = ''
|
||||||
|
auditVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAudit() {
|
||||||
|
if (auditForm.result === 3 && !auditForm.failReason.trim()) {
|
||||||
|
ElMessage.warning('请填写驳回原因')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auditBusy.value = true
|
||||||
|
try {
|
||||||
|
await httpGet('/user/enterprise/checkStatus', {
|
||||||
|
id: enterpriseId.value,
|
||||||
|
status: auditForm.result,
|
||||||
|
failReason: auditForm.result === 3 ? auditForm.failReason.trim() : ''
|
||||||
|
})
|
||||||
|
const passed = auditForm.result === 2
|
||||||
|
ElMessage.success(passed ? '审核通过,企业已认证' : '已驳回,企业认证未通过')
|
||||||
|
auditVisible.value = false
|
||||||
|
// 本会话审计留痕
|
||||||
|
localAuditLogs.value.push({
|
||||||
|
time: fmtDate(new Date().toISOString()),
|
||||||
|
operator: userStore.displayName || '管理员',
|
||||||
|
result: passed ? '审核通过' : '审核驳回',
|
||||||
|
reason: passed ? '认可企业提交的认证资料' : auditForm.failReason.trim()
|
||||||
|
})
|
||||||
|
await load()
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.msg || e?.message || '审核操作失败')
|
||||||
|
} finally {
|
||||||
|
auditBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= 状态切换 (后端均为 query 参数) =================
|
||||||
|
async function toggleEnabled() {
|
||||||
|
const disabling = detail.value.enabled !== 1
|
||||||
|
try { await ElMessageBox.confirm(`${disabling ? '禁用' : '启用'}后,该企业${disabling ? '将无法登录和使用平台服务' : '恢复正常使用'}。确认${disabling ? '禁用' : '启用'}?`, '操作确认', { type: 'warning' }) } catch { return }
|
||||||
|
try {
|
||||||
|
await httpPost(`/user/enterprise/enabled?id=${enterpriseId.value}`)
|
||||||
|
ElMessage.success(disabling ? '已禁用该企业' : '已启用该企业')
|
||||||
|
await load()
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleShow() {
|
||||||
|
const hiding = detail.value.showFlag !== 1
|
||||||
|
try {
|
||||||
|
await httpPost(`/user/enterprise/showEnterprise?id=${enterpriseId.value}`)
|
||||||
|
ElMessage.success(hiding ? '已隐藏企业(列表不再展示)' : '已恢复显示企业')
|
||||||
|
await load()
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleHome() {
|
||||||
|
const recommend = detail.value.homeShow === 1 ? 0 : 1
|
||||||
|
try {
|
||||||
|
await httpGet('/user/enterprise/homeShow', { id: enterpriseId.value, homeShow: recommend })
|
||||||
|
ElMessage.success(recommend === 1 ? '已推荐到首页' : '已取消首页推荐')
|
||||||
|
await load()
|
||||||
|
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
|
||||||
|
}
|
||||||
|
|
||||||
function goStore() {
|
function goStore() {
|
||||||
const eid = detail.value.enterpriseId || detail.value.id
|
const eid = detail.value.enterpriseId || detail.value.id
|
||||||
if (!eid) return
|
if (!eid) return
|
||||||
window.open(`/v2/supplier/${eid}`, '_blank')
|
window.open(`/supplier/${eid}`, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
@ -99,10 +253,105 @@ onMounted(load)
|
|||||||
<template>
|
<template>
|
||||||
<div class="ent-detail" v-loading="loading">
|
<div class="ent-detail" v-loading="loading">
|
||||||
<PageHeader title="企业详情" :sub="`入驻企业信息总览`">
|
<PageHeader title="企业详情" :sub="`入驻企业信息总览`">
|
||||||
<el-button @click="router.back()">返回列表</el-button>
|
<el-button @click="router.back()">
|
||||||
|
<el-icon style="margin-right:4px"><Clock /></el-icon>返回列表
|
||||||
|
</el-button>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<!-- ===== Header 区: logo + 企业名 + 认证标签 + meta ===== -->
|
<!-- ===== ① 状态横幅 (审核操作区) ===== -->
|
||||||
|
<!-- 变更信息对比卡: 有变更记录时展示(待审核/已审核均可见, 方便核对) -->
|
||||||
|
<div v-if="auditChanges.length" class="chg-card">
|
||||||
|
<div class="chg-card-title">
|
||||||
|
<el-icon style="color:var(--primary)"><Document /></el-icon>
|
||||||
|
<span>本次提交变更内容</span>
|
||||||
|
<span class="chg-count">共 {{ auditChanges.length }} 项变更</span>
|
||||||
|
</div>
|
||||||
|
<div class="chg-list">
|
||||||
|
<div v-for="(c, i) in auditChanges" :key="i" class="chg-item">
|
||||||
|
<div class="chg-field">{{ c.field }}</div>
|
||||||
|
<div class="chg-body">
|
||||||
|
<div class="chg-cell old">
|
||||||
|
<span class="chg-tag-old">变更前</span>
|
||||||
|
<div class="chg-val">
|
||||||
|
<template v-if="isImageVal(c.old)">
|
||||||
|
<div class="chg-imgs">
|
||||||
|
<el-image v-for="(img, k) in splitImgs(c.old)" :key="k" :src="img"
|
||||||
|
:preview-src-list="splitImgs(c.old)" :initial-index="k" fit="cover"
|
||||||
|
class="chg-img" preview-teleported />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span v-if="!c.old" class="chg-empty">(未填写)</span>
|
||||||
|
<span v-else>{{ c.old }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chg-arrow">→</div>
|
||||||
|
<div class="chg-cell new">
|
||||||
|
<span class="chg-tag-new">变更后</span>
|
||||||
|
<div class="chg-val">
|
||||||
|
<template v-if="isImageVal(c.new)">
|
||||||
|
<div class="chg-imgs">
|
||||||
|
<el-image v-for="(img, k) in splitImgs(c.new)" :key="k" :src="img"
|
||||||
|
:preview-src-list="splitImgs(c.new)" :initial-index="k" fit="cover"
|
||||||
|
class="chg-img" preview-teleported />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span v-if="!c.new" class="chg-empty">(未填写)</span>
|
||||||
|
<span v-else>{{ c.new }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="audit-banner" :class="bannerClass" v-if="status">
|
||||||
|
<div class="banner-icon">
|
||||||
|
<el-icon v-if="statusMeta" :size="26"><component :is="statusMeta.icon" /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="banner-main">
|
||||||
|
<div class="banner-title">
|
||||||
|
{{ isPending ? '企业认证待审核' : isCertified ? '企业认证已通过' : isRejected ? '企业认证未通过' : '状态未知' }}
|
||||||
|
</div>
|
||||||
|
<div class="banner-desc" v-if="isPending">
|
||||||
|
该企业已提交认证申请资料,请核查营业执照、法人证件及资质证书后给出审核结论。
|
||||||
|
</div>
|
||||||
|
<div class="banner-desc" v-else-if="isRejected">
|
||||||
|
驳回原因:<span class="reason-text">{{ detail.failReason || '未填写' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="banner-desc" v-else-if="isCertified">
|
||||||
|
企业已通过认证,可正常发布商品、参与询报价与交易。
|
||||||
|
</div>
|
||||||
|
<div class="banner-badges">
|
||||||
|
<el-tag v-if="detail.enabled === 1" type="danger" size="small" effect="dark" round>已禁用</el-tag>
|
||||||
|
<el-tag v-if="detail.showFlag === 1" type="info" size="small" effect="plain" round>已隐藏</el-tag>
|
||||||
|
<el-tag v-if="detail.homeShow === 1" type="success" size="small" effect="plain" round>首页推荐</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="banner-actions">
|
||||||
|
<template v-if="isPending">
|
||||||
|
<el-button type="primary" @click="openAudit(2)">
|
||||||
|
<el-icon style="margin-right:4px"><SuccessFilled /></el-icon>审核通过
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" plain @click="openAudit(3)">驳 回</el-button>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="isRejected">
|
||||||
|
<el-button type="primary" @click="openAudit(2)">
|
||||||
|
<el-icon style="margin-right:4px"><SuccessFilled /></el-icon>重新审核
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="isCertified">
|
||||||
|
<el-button size="small" :type="detail.enabled === 1 ? 'success' : 'danger'" plain @click="toggleEnabled">
|
||||||
|
{{ detail.enabled === 1 ? '启用企业' : '禁用企业' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== ② 头部信息卡 ===== -->
|
||||||
<div class="detail-header">
|
<div class="detail-header">
|
||||||
<div class="header-logo">
|
<div class="header-logo">
|
||||||
<el-image v-if="detail.enterpriseLogo" :src="detail.enterpriseLogo" fit="cover" class="logo-img" />
|
<el-image v-if="detail.enterpriseLogo" :src="detail.enterpriseLogo" fit="cover" class="logo-img" />
|
||||||
@ -112,36 +361,80 @@ onMounted(load)
|
|||||||
<div class="header-title-row">
|
<div class="header-title-row">
|
||||||
<h2 class="ent-name">{{ detail.enterpriseName || '--' }}</h2>
|
<h2 class="ent-name">{{ detail.enterpriseName || '--' }}</h2>
|
||||||
<span v-for="(t, i) in tags" :key="i" class="name-tag">{{ t }}</span>
|
<span v-for="(t, i) in tags" :key="i" class="name-tag">{{ t }}</span>
|
||||||
<el-tag v-if="STATUS_MAP[detail.status]" :type="STATUS_MAP[detail.status].type" effect="light" round>{{ STATUS_MAP[detail.status].label }}</el-tag>
|
<el-tag v-if="statusMeta" :type="statusMeta.type" effect="light" round>{{ statusMeta.label }}</el-tag>
|
||||||
<el-tag v-if="detail.enabled === 1" type="info" effect="plain" round>已禁用</el-tag>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="header-meta">
|
<div class="header-meta">
|
||||||
<span v-if="detail.industry" class="meta-item">{{ detail.industry }}</span>
|
<span v-if="detail.industry" class="meta-item">{{ detail.industry }}</span>
|
||||||
<span v-if="registerYear !== '--'" class="meta-item">{{ registerYear }} 成立</span>
|
<span v-if="registerYear !== '--'" class="meta-item">成立于 {{ registerYear }}</span>
|
||||||
|
<span v-if="tenure" class="meta-item">入驻 {{ tenure }}</span>
|
||||||
<span v-if="detail.creditCode" class="meta-item">信用代码 {{ detail.creditCode }}</span>
|
<span v-if="detail.creditCode" class="meta-item">信用代码 {{ detail.creditCode }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<div class="header-stats">
|
<div class="header-stats">
|
||||||
<div class="stat-cell">
|
<div class="stat-cell">
|
||||||
<div class="stat-value">{{ LEVEL_MAP[detail.supplierLevel as number] || '--' }}</div>
|
<div class="stat-value">{{ LEVEL_MAP[levelOf(detail.currentScore)] }}</div>
|
||||||
<div class="stat-label">供应商等级</div>
|
<div class="stat-label">平台等级</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-cell">
|
<div class="stat-cell">
|
||||||
<div class="stat-value">{{ detail.currentScore ?? '--' }}</div>
|
<div class="stat-value" :style="{ color: Number(detail.backfillEnabled) === 1 ? 'var(--el-color-success)' : 'var(--ink-3)' }">
|
||||||
<div class="stat-label">当前评分</div>
|
{{ Number(detail.backfillEnabled) === 1 ? '已开通' : '未开通' }}
|
||||||
|
</div>
|
||||||
|
<div class="stat-label">订单补录权限</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-cell">
|
||||||
|
<div class="stat-value">{{ detail.currentScore ?? 60 }}<span style="font-size:13px;color:var(--ink-3)"> 分</span></div>
|
||||||
|
<div class="stat-label">履约评分</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-cell">
|
<div class="stat-cell">
|
||||||
<div class="stat-value">{{ detail.enterpriseRate ?? '--' }}</div>
|
<div class="stat-value">{{ detail.enterpriseRate ?? '--' }}</div>
|
||||||
<div class="stat-label">企业评级</div>
|
<div class="stat-label">好评率</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" class="store-btn" @click="goStore" :disabled="!detail.enterpriseId && !detail.id">
|
<div class="header-btns">
|
||||||
<el-icon style="margin-right:6px"><Shop /></el-icon>进入店铺
|
<el-button type="primary" class="store-btn" @click="goStore" :disabled="!detail.enterpriseId && !detail.id">
|
||||||
</el-button>
|
<el-icon style="margin-right:6px"><Shop /></el-icon>进入店铺
|
||||||
|
</el-button>
|
||||||
|
<el-dropdown trigger="click" placement="bottom-end" v-if="isCertified">
|
||||||
|
<el-button plain>
|
||||||
|
更多操作<el-icon class="el-icon--right"><Coin /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item :command="1" @click="toggleShow">{{ detail.showFlag === 1 ? '恢复显示' : '隐藏企业' }}</el-dropdown-item>
|
||||||
|
<el-dropdown-item :command="2" @click="toggleHome">{{ detail.homeShow === 1 ? '取消首页推荐' : '推荐到首页' }}</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== ③ 认证流程时间线 ===== -->
|
||||||
|
<div class="info-card timeline-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<el-icon class="card-head-icon"><Medal /></el-icon>
|
||||||
|
<span class="card-title-text">认证流程</span>
|
||||||
|
<span class="card-sub">入驻申请 · 审核结果 · 本会话操作记录</span>
|
||||||
|
</div>
|
||||||
|
<el-timeline class="audit-timeline">
|
||||||
|
<el-timeline-item
|
||||||
|
v-for="(item, i) in timelineItems"
|
||||||
|
:key="i"
|
||||||
|
:type="item.kind === 'pass' ? 'success' : item.kind === 'fail' ? 'danger' : item.kind === 'pending' ? 'warning' : 'primary'"
|
||||||
|
:hollow="item.kind === 'pending'"
|
||||||
|
:timestamp="item.time"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<div class="tl-content">
|
||||||
|
<span class="tl-title">{{ item.title }}</span>
|
||||||
|
<span class="tl-desc">{{ item.desc }}</span>
|
||||||
|
<span v-if="item.operator" class="tl-operator">操作人:{{ item.operator }}</span>
|
||||||
|
</div>
|
||||||
|
</el-timeline-item>
|
||||||
|
</el-timeline>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ===== Tab 导航 ===== -->
|
<!-- ===== Tab 导航 ===== -->
|
||||||
<div class="tab-nav">
|
<div class="tab-nav">
|
||||||
<div class="tab-item" :class="{ active: activeTab === 'basic' }" @click="activeTab = 'basic'">基本信息</div>
|
<div class="tab-item" :class="{ active: activeTab === 'basic' }" @click="activeTab = 'basic'">基本信息</div>
|
||||||
@ -154,21 +447,27 @@ onMounted(load)
|
|||||||
<div class="info-grid">
|
<div class="info-grid">
|
||||||
<div class="info-col">
|
<div class="info-col">
|
||||||
<section class="info-card">
|
<section class="info-card">
|
||||||
<div class="card-title">公司信息</div>
|
<div class="card-head">
|
||||||
|
<el-icon class="card-head-icon"><OfficeBuilding /></el-icon>
|
||||||
|
<span class="card-title-text">公司信息</span>
|
||||||
|
</div>
|
||||||
<div class="info-grid-2">
|
<div class="info-grid-2">
|
||||||
<div class="info-item"><span class="label">公司名称</span><span class="value">{{ detail.enterpriseName || '--' }}</span></div>
|
<div class="info-item"><span class="label">公司名称</span><span class="value">{{ detail.enterpriseName || '--' }}</span></div>
|
||||||
<div class="info-item"><span class="label">注册时间</span><span class="value">{{ fmtDate(detail.registerTime) }}</span></div>
|
<div class="info-item"><span class="label">注册时间</span><span class="value">{{ fmtDate(detail.registerTime) }}</span></div>
|
||||||
<div class="info-item"><span class="label">所属行业</span><span class="value">{{ detail.industry || '--' }}</span></div>
|
<div class="info-item"><span class="label">所属行业</span><span class="value">{{ detail.industry || '--' }}</span></div>
|
||||||
<div class="info-item"><span class="label">企业身份</span><span class="value">{{ IDENTITY_MAP[detail.enterpriseIdentity as number] || detail.enterpriseIdentity || '--' }}</span></div>
|
<div class="info-item"><span class="label">企业身份</span><span class="value">{{ IDENTITY_MAP[detail.enterpriseIdentity as number] || detail.enterpriseIdentity || '--' }}</span></div>
|
||||||
<div class="info-item"><span class="label">企业类型</span><span class="value">{{ detail.enterpriseType ?? '--' }}</span></div>
|
<div class="info-item"><span class="label">企业类型</span><span class="value">{{ TYPE_MAP[detail.enterpriseType as number] || detail.enterpriseType || '--' }}</span></div>
|
||||||
<div class="info-item"><span class="label">统一信用代码</span><span class="value">{{ detail.creditCode || detail.orgNo || '--' }}</span></div>
|
<div class="info-item"><span class="label">统一信用代码</span><span class="value mono">{{ detail.creditCode || detail.orgNo || '--' }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="info-card">
|
<section class="info-card">
|
||||||
<div class="card-title">地址信息</div>
|
<div class="card-head">
|
||||||
|
<el-icon class="card-head-icon"><MapLocation /></el-icon>
|
||||||
|
<span class="card-title-text">地址信息</span>
|
||||||
|
</div>
|
||||||
<div class="address-box">
|
<div class="address-box">
|
||||||
<div class="addr-line">{{ [detail.provinceName, detail.cityName, detail.areaName].filter(Boolean).join(' ') || '--' }}</div>
|
<div class="addr-line">{{ addressLine || '--' }}</div>
|
||||||
<div class="value">{{ detail.enterpriseAddress || '--' }}</div>
|
<div class="value">{{ detail.enterpriseAddress || '--' }}</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -176,31 +475,29 @@ onMounted(load)
|
|||||||
|
|
||||||
<div class="info-col">
|
<div class="info-col">
|
||||||
<section class="info-card">
|
<section class="info-card">
|
||||||
<div class="card-title">联系人信息</div>
|
<div class="card-head">
|
||||||
|
<el-icon class="card-head-icon"><User /></el-icon>
|
||||||
|
<span class="card-title-text">联系人信息</span>
|
||||||
|
</div>
|
||||||
<div class="contact-profile">
|
<div class="contact-profile">
|
||||||
<div class="contact-avatar">{{ contactInitial }}</div>
|
<div class="contact-avatar">{{ contactInitial }}</div>
|
||||||
<div class="contact-name">{{ detail.linkMan || '--' }}</div>
|
<div class="contact-name">{{ detail.linkMan || '--' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="contact-methods">
|
<div class="contact-methods">
|
||||||
<div class="contact-method">
|
<div class="contact-method"><span class="label">联系电话</span><span class="value">{{ maskMobile(detail.linkMobile) }}</span></div>
|
||||||
<span class="label">联系电话</span><span class="value">{{ maskMobile(detail.linkMobile) }}</span>
|
<div class="contact-method"><span class="label">电子邮箱</span><span class="value">{{ detail.email || '--' }}</span></div>
|
||||||
</div>
|
<div class="contact-method"><span class="label">管理员</span><span class="value">{{ detail.administrator || '--' }} ({{ maskMobile(detail.administratorMobile) }})</span></div>
|
||||||
<div class="contact-method">
|
<div class="contact-method"><span class="label">业务员</span><span class="value">{{ detail.salesperson || '暂未分配' }}</span></div>
|
||||||
<span class="label">电子邮箱</span><span class="value">{{ detail.email || '--' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="contact-method">
|
|
||||||
<span class="label">管理员</span><span class="value">{{ detail.administrator || '--' }} ({{ maskMobile(detail.administratorMobile) }})</span>
|
|
||||||
</div>
|
|
||||||
<div class="contact-method">
|
|
||||||
<span class="label">业务员</span><span class="value">{{ detail.salesperson || '暂未分配' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="info-card full">
|
<section class="info-card full">
|
||||||
<div class="card-title">经营信息</div>
|
<div class="card-head">
|
||||||
|
<el-icon class="card-head-icon"><Coin /></el-icon>
|
||||||
|
<span class="card-title-text">经营信息</span>
|
||||||
|
</div>
|
||||||
<div class="info-grid-2">
|
<div class="info-grid-2">
|
||||||
<div class="info-item span2"><span class="label">经营范围</span><span class="value">{{ detail.scope || detail.scopeOfLicence || '--' }}</span></div>
|
<div class="info-item span2"><span class="label">经营范围</span><span class="value">{{ detail.scope || detail.scopeOfLicence || '--' }}</span></div>
|
||||||
<div class="info-item span2"><span class="label">主营产品</span>
|
<div class="info-item span2"><span class="label">主营产品</span>
|
||||||
@ -209,10 +506,10 @@ onMounted(load)
|
|||||||
<span v-if="!productList.length" class="value">--</span>
|
<span v-if="!productList.length" class="value">--</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item"><span class="label">银行</span><span class="value">{{ detail.bankOfDeposit || '--' }}</span></div>
|
<div class="info-item"><span class="label">开户银行</span><span class="value">{{ detail.bankOfDeposit || '--' }}</span></div>
|
||||||
<div class="info-item"><span class="label">开户名称</span><span class="value">{{ detail.accountName || '--' }}</span></div>
|
|
||||||
<div class="info-item"><span class="label">账号</span><span class="value">{{ detail.accountCard || '--' }}</span></div>
|
|
||||||
<div class="info-item"><span class="label">账户余额</span><span class="value">{{ fmtMoney(detail.accountBalance) }}</span></div>
|
<div class="info-item"><span class="label">账户余额</span><span class="value">{{ fmtMoney(detail.accountBalance) }}</span></div>
|
||||||
|
<div class="info-item"><span class="label">开户名称</span><span class="value">{{ detail.accountName || '--' }}</span></div>
|
||||||
|
<div class="info-item"><span class="label">银行账号</span><span class="value mono">{{ detail.accountCard || '--' }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@ -220,21 +517,29 @@ onMounted(load)
|
|||||||
<!-- ===== Tab 2: 资质认证 ===== -->
|
<!-- ===== Tab 2: 资质认证 ===== -->
|
||||||
<div v-show="activeTab === 'qual'" class="tab-body">
|
<div v-show="activeTab === 'qual'" class="tab-body">
|
||||||
<section class="info-card full">
|
<section class="info-card full">
|
||||||
<div class="card-title">营业执照</div>
|
<div class="card-head">
|
||||||
|
<el-icon class="card-head-icon"><DocumentChecked /></el-icon>
|
||||||
|
<span class="card-title-text">营业执照</span>
|
||||||
|
<span class="card-sub">点击图片可放大预览</span>
|
||||||
|
</div>
|
||||||
<el-image v-if="detail.licenseImg" :src="detail.licenseImg" fit="contain" class="cert-img" :preview-src-list="[detail.licenseImg]" preview-teleported />
|
<el-image v-if="detail.licenseImg" :src="detail.licenseImg" fit="contain" class="cert-img" :preview-src-list="[detail.licenseImg]" preview-teleported />
|
||||||
<div v-else class="cert-empty">暂无营业执照</div>
|
<div v-else class="cert-empty"><span>暂无营业执照</span></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="info-card full">
|
<section class="info-card full">
|
||||||
<div class="card-title">法人身份证</div>
|
<div class="card-head">
|
||||||
|
<el-icon class="card-head-icon"><DocumentChecked /></el-icon>
|
||||||
|
<span class="card-title-text">法人身份证</span>
|
||||||
|
<span class="card-sub">点击图片可放大预览</span>
|
||||||
|
</div>
|
||||||
<div class="idcard-row">
|
<div class="idcard-row">
|
||||||
<div class="idcard-cell">
|
<div class="idcard-cell">
|
||||||
<span class="label">正面</span>
|
<span class="label">正面(人像面)</span>
|
||||||
<el-image v-if="detail.frontImage" :src="detail.frontImage" fit="cover" class="idcard-img" :preview-src-list="[detail.frontImage]" preview-teleported />
|
<el-image v-if="detail.frontImage" :src="detail.frontImage" fit="cover" class="idcard-img" :preview-src-list="[detail.frontImage]" preview-teleported />
|
||||||
<div v-else class="cert-empty small">暂无</div>
|
<div v-else class="cert-empty small">暂无</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="idcard-cell">
|
<div class="idcard-cell">
|
||||||
<span class="label">反面</span>
|
<span class="label">反面(国徽面)</span>
|
||||||
<el-image v-if="detail.backImage" :src="detail.backImage" fit="cover" class="idcard-img" :preview-src-list="[detail.backImage]" preview-teleported />
|
<el-image v-if="detail.backImage" :src="detail.backImage" fit="cover" class="idcard-img" :preview-src-list="[detail.backImage]" preview-teleported />
|
||||||
<div v-else class="cert-empty small">暂无</div>
|
<div v-else class="cert-empty small">暂无</div>
|
||||||
</div>
|
</div>
|
||||||
@ -243,33 +548,77 @@ onMounted(load)
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="info-card full">
|
<section class="info-card full">
|
||||||
<div class="card-title">资质证书</div>
|
<div class="card-head">
|
||||||
|
<el-icon class="card-head-icon"><Medal /></el-icon>
|
||||||
|
<span class="card-title-text">资质证书</span>
|
||||||
|
<span class="card-sub" v-if="otherList.length">共 {{ otherList.length }} 项</span>
|
||||||
|
</div>
|
||||||
<template v-if="otherList.length">
|
<template v-if="otherList.length">
|
||||||
<div v-for="(cert, i) in otherList" :key="i" class="cert-card">
|
<div class="cert-card" v-for="(cert, i) in otherList" :key="i">
|
||||||
<div class="cert-info">
|
<div class="cert-main">
|
||||||
<div class="cert-row"><span class="label">资质名称</span><span class="value">{{ cert.qualificationName || '--' }}</span></div>
|
<div class="cert-info">
|
||||||
<div class="cert-row"><span class="label">证书编号</span><span class="value">{{ cert.certificateNumber || '--' }}</span></div>
|
<div class="cert-row"><span class="label">资质名称</span><span class="value">{{ cert.qualificationName || '--' }}</span></div>
|
||||||
<div class="cert-row"><span class="label">有效期至</span><span class="value">{{ cert.expirationDate || '--' }}</span></div>
|
<div class="cert-row"><span class="label">证书编号</span><span class="value mono">{{ cert.certificateNumber || '--' }}</span></div>
|
||||||
</div>
|
<div class="cert-row"><span class="label">有效期至</span>
|
||||||
<div v-if="cert.imageList?.length" class="cert-imgs">
|
<span class="value">{{ cert.expirationDate || '--' }}
|
||||||
<el-image v-for="(img, j) in cert.imageList" :key="j" :src="img" fit="cover" class="cert-thumb" :preview-src-list="cert.imageList" preview-teleported />
|
<el-tag v-if="cert.isExpiration === 1" type="danger" size="small" style="margin-left:6px">已过期</el-tag>
|
||||||
|
<el-tag v-else-if="cert.expirationDate" type="success" size="small" style="margin-left:6px">有效</el-tag>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="cert.imageList?.length" class="cert-imgs">
|
||||||
|
<template v-for="(img, j) in cert.imageList" :key="j">
|
||||||
|
<a v-if="isPdfUrl(img)" :href="img" target="_blank" rel="noopener"
|
||||||
|
class="pdf-card" :title="'打开 PDF 文件'">
|
||||||
|
<el-icon style="font-size:22px;color:#e34d4d"><Document /></el-icon>
|
||||||
|
<span style="font-size:11px;font-weight:bold;color:#e34d4d">PDF</span>
|
||||||
|
</a>
|
||||||
|
<el-image v-else :src="img" fit="cover" class="cert-thumb" :preview-src-list="[img]" preview-teleported />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="cert-empty">暂无资质证书</div>
|
<div v-else class="cert-empty"><span>暂无资质证书</span></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="info-card full">
|
<section class="info-card full">
|
||||||
<div class="card-title">企业授权书</div>
|
<div class="card-head">
|
||||||
|
<el-icon class="card-head-icon"><DocumentChecked /></el-icon>
|
||||||
|
<span class="card-title-text">企业授权书</span>
|
||||||
|
<span class="card-sub">点击图片可放大预览</span>
|
||||||
|
</div>
|
||||||
<el-image v-if="detail.enterpriseAccredit" :src="detail.enterpriseAccredit" fit="cover" class="cert-img tall" :preview-src-list="[detail.enterpriseAccredit]" preview-teleported />
|
<el-image v-if="detail.enterpriseAccredit" :src="detail.enterpriseAccredit" fit="cover" class="cert-img tall" :preview-src-list="[detail.enterpriseAccredit]" preview-teleported />
|
||||||
<div v-else class="cert-empty">暂无授权书</div>
|
<div v-else class="cert-empty"><span>暂无授权书</span></div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ===== Tab 3: 履约情况 ===== -->
|
<!-- ===== Tab 3: 履约情况 ===== -->
|
||||||
<div v-show="activeTab === 'perf'" class="tab-body">
|
<div v-show="activeTab === 'perf'" class="tab-body">
|
||||||
|
<div class="kpi-grid">
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-label">企业评分</div>
|
||||||
|
<div class="kpi-value primary">{{ fmtNum(detail.enterpriseScore) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-label">好评率</div>
|
||||||
|
<div class="kpi-value">{{ fmtNum(detail.enterpriseRate) }}<span class="kpi-unit">%</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-label">评价总数</div>
|
||||||
|
<div class="kpi-value">{{ fmtNum(detail.enterpriseCommentNum) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-label">在售商品</div>
|
||||||
|
<div class="kpi-value">{{ fmtNum(detail.physicalGoodsNum) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="info-card full">
|
<section class="info-card full">
|
||||||
<div class="card-title">履约评分记录</div>
|
<div class="card-head">
|
||||||
|
<el-icon class="card-head-icon"><Star /></el-icon>
|
||||||
|
<span class="card-title-text">履约评分记录</span>
|
||||||
|
</div>
|
||||||
<template v-if="performanceList.length">
|
<template v-if="performanceList.length">
|
||||||
<div v-for="(item, i) in performanceList" :key="i" class="perf-item">
|
<div v-for="(item, i) in performanceList" :key="i" class="perf-item">
|
||||||
<span class="perf-date">{{ fmtDate(item.date) }}</span>
|
<span class="perf-date">{{ fmtDate(item.date) }}</span>
|
||||||
@ -277,24 +626,91 @@ onMounted(load)
|
|||||||
<span class="perf-score">{{ item.score ?? '--' }} 分</span>
|
<span class="perf-score">{{ item.score ?? '--' }} 分</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="cert-empty">暂无履约评分记录</div>
|
<div v-else class="perf-empty">暂无履约评分记录</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== 审核弹窗 ===== -->
|
||||||
|
<el-dialog v-model="auditVisible" title="企业认证审核" width="500px" :close-on-click-modal="false" destroy-on-close>
|
||||||
|
<div class="audit-dialog-tip">
|
||||||
|
正在审核:<strong>{{ detail.enterpriseName || '--' }}</strong>
|
||||||
|
</div>
|
||||||
|
<el-form label-width="80px" label-position="left">
|
||||||
|
<el-form-item label="审核结果">
|
||||||
|
<el-radio-group v-model="auditForm.result">
|
||||||
|
<el-radio :value="2">审核通过</el-radio>
|
||||||
|
<el-radio :value="3">审核驳回</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="auditForm.result === 3" label="驳回原因" required>
|
||||||
|
<el-input
|
||||||
|
v-model="auditForm.failReason"
|
||||||
|
type="textarea" :rows="4"
|
||||||
|
placeholder="请填写驳回原因,将随站内消息与短信通知企业"
|
||||||
|
maxlength="200" show-word-limit
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-else label="通过说明">
|
||||||
|
<div class="pass-tip">审核通过后将自动:完成企业认证、初始化审批流、创建默认分类与默认支付方式,并通过站内消息与短信通知企业。</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="auditVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="auditBusy" @click="submitAudit">确定提交</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.ent-detail { max-width: 1400px; }
|
.ent-detail { max-width: 1400px; }
|
||||||
|
|
||||||
/* ===== Header ===== */
|
/* ===== ① 状态横幅 ===== */
|
||||||
|
.audit-banner {
|
||||||
|
display: flex; align-items: center; gap: 18px;
|
||||||
|
border-radius: var(--radius-lg); padding: 16px 22px; margin-bottom: 16px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.banner-warning {
|
||||||
|
background: linear-gradient(90deg, color-mix(in srgb, var(--st-warn-bg) 70%, transparent), transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--st-warn-fg) 35%, transparent);
|
||||||
|
.banner-icon { background: var(--st-warn-bg); color: var(--st-warn-fg); }
|
||||||
|
}
|
||||||
|
.banner-success {
|
||||||
|
background: linear-gradient(90deg, color-mix(in srgb, var(--st-ok-bg) 70%, transparent), transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--st-ok-fg) 30%, transparent);
|
||||||
|
.banner-icon { background: var(--st-ok-bg); color: var(--st-ok-fg); }
|
||||||
|
}
|
||||||
|
.banner-danger {
|
||||||
|
background: linear-gradient(90deg, color-mix(in srgb, var(--st-err-bg) 70%, transparent), transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--st-err-fg) 32%, transparent);
|
||||||
|
.banner-icon { background: var(--st-err-bg); color: var(--st-err-fg); }
|
||||||
|
}
|
||||||
|
.banner-unknown {
|
||||||
|
background: var(--bg, #f6f8fb); border-color: var(--border);
|
||||||
|
.banner-icon { background: var(--border); color: var(--ink-3); }
|
||||||
|
}
|
||||||
|
.banner-icon {
|
||||||
|
flex: 0 0 48px; width: 48px; height: 48px; border-radius: 12px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.banner-main { flex: 1; min-width: 0; }
|
||||||
|
.banner-title { font-size: 16px; font-weight: 700; color: var(--ink); }
|
||||||
|
.banner-desc { font-size: 13px; color: var(--ink-2); margin-top: 4px; line-height: 1.6; }
|
||||||
|
.reason-text { color: #b91c1c; font-weight: 600; }
|
||||||
|
.banner-badges { display: flex; gap: 8px; margin-top: 8px; }
|
||||||
|
.banner-actions { display: flex; gap: 10px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* ===== ② 头部信息卡 ===== */
|
||||||
.detail-header {
|
.detail-header {
|
||||||
display: flex; gap: 24px; align-items: center;
|
display: flex; gap: 24px; align-items: center;
|
||||||
background: var(--surface); border: 1px solid var(--border);
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg); padding: 24px 28px; margin-bottom: 16px;
|
border-radius: var(--radius-lg); padding: 24px 28px; margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
.header-logo {
|
.header-logo {
|
||||||
flex: 0 0 88px; width: 88px; height: 88px; border-radius: 12px; overflow: hidden;
|
flex: 0 0 88px; width: 88px; height: 88px; border-radius: 14px; overflow: hidden;
|
||||||
background: linear-gradient(135deg, var(--primary), var(--primary-soft, #93c5fd));
|
background: linear-gradient(135deg, var(--primary), var(--primary-soft, #93c5fd));
|
||||||
|
box-shadow: 0 6px 16px -8px var(--primary);
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
}
|
}
|
||||||
.logo-img { width: 100%; height: 100%; }
|
.logo-img { width: 100%; height: 100%; }
|
||||||
@ -306,16 +722,21 @@ onMounted(load)
|
|||||||
background: var(--primary-soft, #e0ecff); color: var(--primary);
|
background: var(--primary-soft, #e0ecff); color: var(--primary);
|
||||||
font-size: 12px; padding: 2px 12px; border-radius: 20px; white-space: nowrap;
|
font-size: 12px; padding: 2px 12px; border-radius: 20px; white-space: nowrap;
|
||||||
}
|
}
|
||||||
.header-meta { display: flex; gap: 20px; flex-wrap: wrap; color: var(--ink-2); font-size: 13px; margin-bottom: 10px; }
|
.header-meta { display: flex; gap: 20px; flex-wrap: wrap; color: var(--ink-2); font-size: 13px; }
|
||||||
.meta-item::before { content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--primary); margin-right: 6px; vertical-align: middle; }
|
.meta-item::before {
|
||||||
|
content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%;
|
||||||
|
background: var(--primary); margin-right: 6px; vertical-align: middle;
|
||||||
|
}
|
||||||
.header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 14px; }
|
.header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 14px; }
|
||||||
.header-stats { display: flex; gap: 32px; }
|
.header-stats { display: flex; gap: 32px; }
|
||||||
.stat-cell { text-align: center; }
|
.stat-cell { text-align: center; }
|
||||||
.stat-value { font-size: 20px; font-weight: 700; color: var(--ink); }
|
.stat-value { font-size: 18px; font-weight: 700; color: var(--ink); }
|
||||||
.stat-label { font-size: 12px; color: var(--ink-3); margin-top: 4px; }
|
.stat-label { font-size: 12px; color: var(--ink-3); margin-top: 4px; }
|
||||||
|
.header-btns { display: flex; gap: 10px; }
|
||||||
.store-btn { font-weight: 600; }
|
.store-btn { font-weight: 600; }
|
||||||
|
.mono { font-family: 'SF Mono', Menlo, Consolas, monospace; font-size: 13px; }
|
||||||
|
|
||||||
/* ===== Tabs ===== */
|
/* ===== Tab 导航 ===== */
|
||||||
.tab-nav {
|
.tab-nav {
|
||||||
display: flex; gap: 36px; border-bottom: 1px solid var(--border);
|
display: flex; gap: 36px; border-bottom: 1px solid var(--border);
|
||||||
padding: 0 8px; margin-bottom: 20px;
|
padding: 0 8px; margin-bottom: 20px;
|
||||||
@ -327,7 +748,7 @@ onMounted(load)
|
|||||||
.tab-item:hover { color: var(--ink); }
|
.tab-item:hover { color: var(--ink); }
|
||||||
.tab-item.active { color: var(--primary); font-weight: 600; border-bottom-color: var(--primary); }
|
.tab-item.active { color: var(--primary); font-weight: 600; border-bottom-color: var(--primary); }
|
||||||
|
|
||||||
/* ===== Body / cards ===== */
|
/* ===== 卡片通用 ===== */
|
||||||
.tab-body { display: flex; flex-direction: column; gap: 16px; }
|
.tab-body { display: flex; flex-direction: column; gap: 16px; }
|
||||||
.info-grid { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 16px; }
|
.info-grid { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 16px; }
|
||||||
.info-col { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
|
.info-col { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
|
||||||
@ -336,10 +757,17 @@ onMounted(load)
|
|||||||
border-radius: var(--radius-lg); padding: 22px 24px;
|
border-radius: var(--radius-lg); padding: 22px 24px;
|
||||||
}
|
}
|
||||||
.info-card.full { width: 100%; }
|
.info-card.full { width: 100%; }
|
||||||
.card-title {
|
.card-head {
|
||||||
font-size: 15px; font-weight: 700; color: var(--ink); margin-bottom: 18px;
|
display: flex; align-items: center; gap: 8px; margin-bottom: 18px;
|
||||||
padding-left: 10px; border-left: 3px solid var(--primary);
|
|
||||||
}
|
}
|
||||||
|
.card-head-icon {
|
||||||
|
width: 26px; height: 26px; border-radius: 8px;
|
||||||
|
background: var(--primary-soft, #e0ecff); color: var(--primary);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.card-title-text { font-size: 15px; font-weight: 700; color: var(--ink); }
|
||||||
|
.card-sub { font-size: 12px; color: var(--ink-3); margin-left: auto; }
|
||||||
|
|
||||||
.info-grid-2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 48px; }
|
.info-grid-2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 48px; }
|
||||||
.info-item { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
.info-item { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||||
.info-item.span2 { grid-column: span 2; }
|
.info-item.span2 { grid-column: span 2; }
|
||||||
@ -371,30 +799,52 @@ onMounted(load)
|
|||||||
font-size: 12px; padding: 3px 14px; border-radius: 6px;
|
font-size: 12px; padding: 3px 14px; border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== 时间线 ===== */
|
||||||
|
.timeline-card { margin-bottom: 16px; }
|
||||||
|
.audit-timeline { padding-left: 6px; }
|
||||||
|
.tl-content { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.tl-title { font-size: 14px; font-weight: 600; color: var(--ink); }
|
||||||
|
.tl-desc { font-size: 13px; color: var(--ink-2); }
|
||||||
|
.tl-operator { font-size: 12px; color: var(--ink-3); background: var(--bg, #f6f8fb); padding: 2px 10px; border-radius: 12px; }
|
||||||
|
|
||||||
/* ===== 资质 ===== */
|
/* ===== 资质 ===== */
|
||||||
.cert-img { width: 220px; height: 150px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg, #f6f8fb); }
|
.cert-img {
|
||||||
.cert-img.tall { width: 160px; height: 200px; }
|
width: 240px; height: 160px; border-radius: 8px; border: 1px solid var(--border);
|
||||||
|
background: var(--bg, #f6f8fb); cursor: zoom-in;
|
||||||
|
}
|
||||||
|
.cert-img.tall { width: 160px; height: 210px; }
|
||||||
.cert-empty {
|
.cert-empty {
|
||||||
width: 220px; height: 120px; border: 1px dashed var(--border); border-radius: 8px;
|
width: 240px; height: 120px; border: 1px dashed var(--border); border-radius: 8px;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
color: var(--ink-3); font-size: 13px; background: var(--bg, #f6f8fb);
|
color: var(--ink-3); font-size: 13px; background: var(--bg, #f6f8fb);
|
||||||
}
|
}
|
||||||
.cert-empty.small { width: 150px; height: 100px; }
|
.cert-empty.small { width: 160px; height: 110px; }
|
||||||
.idcard-row { display: flex; gap: 24px; margin-bottom: 16px; }
|
.idcard-row { display: flex; gap: 32px; margin-bottom: 16px; }
|
||||||
.idcard-cell { display: flex; flex-direction: column; gap: 8px; }
|
.idcard-cell { display: flex; flex-direction: column; gap: 8px; }
|
||||||
.idcard-img { width: 180px; height: 120px; border-radius: 8px; border: 1px solid var(--border); }
|
.idcard-img { width: 200px; height: 130px; border-radius: 8px; border: 1px solid var(--border); cursor: zoom-in; }
|
||||||
.legal-row { display: flex; gap: 12px; align-items: baseline; }
|
.legal-row { display: flex; gap: 12px; align-items: baseline; }
|
||||||
|
|
||||||
.cert-card {
|
.cert-card {
|
||||||
border: 1px solid var(--border); border-radius: 10px; padding: 16px 18px;
|
border: 1px solid var(--border); border-radius: 10px; padding: 16px 18px;
|
||||||
margin-bottom: 14px; background: var(--bg, #fafbfd);
|
margin-bottom: 14px; background: var(--bg, #fafbfd);
|
||||||
}
|
}
|
||||||
.cert-row { display: flex; gap: 12px; margin-bottom: 6px; }
|
.cert-row { display: flex; gap: 12px; margin-bottom: 6px; align-items: baseline; }
|
||||||
.cert-row .label { min-width: 76px; }
|
.cert-row .label { min-width: 76px; }
|
||||||
.cert-imgs { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 10px; }
|
.cert-imgs { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 12px; }
|
||||||
.cert-thumb { width: 100px; height: 120px; border-radius: 6px; border: 1px solid var(--border); }
|
.cert-thumb { width: 96px; height: 120px; border-radius: 6px; border: 1px solid var(--border); cursor: zoom-in; }
|
||||||
|
.pdf-card { width: 96px; height: 120px; border-radius: 6px; background: #fef2f2; border: 1px solid #fecaca; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; cursor: pointer; text-decoration: none; }
|
||||||
|
.pdf-card:hover { border-color: #fca5a5; background: #fee2e2; }
|
||||||
|
|
||||||
/* ===== 履约 ===== */
|
/* ===== 履约 ===== */
|
||||||
|
.kpi-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
|
||||||
|
.kpi-card {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg); padding: 20px 24px;
|
||||||
|
}
|
||||||
|
.kpi-label { font-size: 12px; color: var(--ink-3); margin-bottom: 10px; }
|
||||||
|
.kpi-value { font-size: 28px; font-weight: 700; color: var(--ink); line-height: 1; }
|
||||||
|
.kpi-value.primary { color: var(--primary); }
|
||||||
|
.kpi-unit { font-size: 14px; color: var(--ink-3); margin-left: 2px; }
|
||||||
.perf-item {
|
.perf-item {
|
||||||
display: flex; align-items: center; gap: 16px; padding: 12px 0;
|
display: flex; align-items: center; gap: 16px; padding: 12px 0;
|
||||||
border-bottom: 1px solid var(--border-light, var(--border));
|
border-bottom: 1px solid var(--border-light, var(--border));
|
||||||
@ -403,9 +853,51 @@ onMounted(load)
|
|||||||
.perf-date { font-size: 13px; color: var(--ink-3); min-width: 140px; }
|
.perf-date { font-size: 13px; color: var(--ink-3); min-width: 140px; }
|
||||||
.perf-content { flex: 1; font-size: 14px; color: var(--ink); }
|
.perf-content { flex: 1; font-size: 14px; color: var(--ink); }
|
||||||
.perf-score { font-size: 15px; font-weight: 700; color: var(--primary); }
|
.perf-score { font-size: 15px; font-weight: 700; color: var(--primary); }
|
||||||
|
.perf-empty { color: var(--ink-3); font-size: 13px; padding: 24px 0; text-align: center; }
|
||||||
|
|
||||||
|
/* ===== 审核弹窗 ===== */
|
||||||
|
.audit-dialog-tip {
|
||||||
|
background: var(--bg, #f6f8fb); border: 1px solid var(--border);
|
||||||
|
border-radius: 8px; padding: 10px 14px; font-size: 13px; color: var(--ink-2);
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.pass-tip {
|
||||||
|
font-size: 12px; color: var(--ink-3); line-height: 1.7;
|
||||||
|
background: var(--bg, #f6f8fb); border-radius: 8px; padding: 10px 12px; width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.info-grid { grid-template-columns: 1fr; }
|
.info-grid { grid-template-columns: 1fr; }
|
||||||
.header-stats { display: none; }
|
.header-stats { display: none; }
|
||||||
|
.kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.banner-actions { flex-direction: column; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== 变更信息对比卡 ===== */
|
||||||
|
.chg-card {
|
||||||
|
background: #fff; border: 1px solid var(--border-1, #e5e7eb); border-radius: 10px;
|
||||||
|
padding: 14px 18px; margin-bottom: 14px; box-shadow: 0 1px 2px rgba(0,0,0,.03);
|
||||||
|
}
|
||||||
|
.chg-card-title {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
font-size: 14px; font-weight: 700; color: var(--ink); margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.chg-count { font-size: 12px; font-weight: 500; color: var(--primary); }
|
||||||
|
.chg-list { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.chg-item {
|
||||||
|
display: flex; gap: 12px; align-items: stretch;
|
||||||
|
background: var(--bg-1, #fafafa); border: 1px solid var(--border-1, #ececec); border-radius: 8px; padding: 10px 14px;
|
||||||
|
}
|
||||||
|
.chg-field { flex: 0 0 110px; display: flex; align-items: center; font-size: 13px; font-weight: 600; color: var(--ink-2); }
|
||||||
|
.chg-body { flex: 1; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.chg-cell { flex: 1; min-width: 200px; display: flex; gap: 8px; align-items: flex-start; }
|
||||||
|
.chg-cell.old { background: #f7f8fa; border-radius: 6px; padding: 8px 10px; }
|
||||||
|
.chg-cell.new { background: #f0f9eb; border-radius: 6px; padding: 8px 10px; }
|
||||||
|
.chg-tag-old { flex-shrink: 0; font-size: 11px; font-weight: 600; color: var(--ink-3); background: #ececec; border-radius: 4px; padding: 1px 6px; margin-top: 2px; }
|
||||||
|
.chg-tag-new { flex-shrink: 0; font-size: 11px; font-weight: 600; color: var(--el-color-success,#67c23a); background: #e1f3d8; border-radius: 4px; padding: 1px 6px; margin-top: 2px; }
|
||||||
|
.chg-val { font-size: 13px; color: var(--ink); word-break: break-all; line-height: 1.6; }
|
||||||
|
.chg-empty { color: var(--ink-3); }
|
||||||
|
.chg-arrow { flex-shrink: 0; align-self: center; color: var(--ink-3); font-size: 16px; }
|
||||||
|
.chg-imgs { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.chg-img { width: 56px; height: 56px; border-radius: 6px; border: 1px solid var(--border-1, #ececec); cursor: zoom-in; }
|
||||||
</style>
|
</style>
|
||||||
@ -1,8 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* F006 企业等级管理 - W16
|
* F006 企业等级管理 - W16 + UI 审计补完 (2026-08-22)
|
||||||
*
|
|
||||||
* 沿用老 vue user/enterprise/enterprise-level.vue 字段
|
|
||||||
*
|
*
|
||||||
* 端点: 4 URL 探测 (enterpriselevel/page, /list, /search, /queryPage); 404 → FALLBACK seed
|
* 端点: 4 URL 探测 (enterpriselevel/page, /list, /search, /queryPage); 404 → FALLBACK seed
|
||||||
*/
|
*/
|
||||||
@ -10,36 +8,34 @@ import { ref, reactive, onMounted, computed } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { httpGet } from '@/utils/request'
|
import { httpGet } from '@/utils/request'
|
||||||
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||||
import SearchBar from '@/components/SearchBar/SearchBar.vue'
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||||
import TableActions from '@/components/TableActions/TableActions.vue'
|
import TableActions from '@/components/TableActions/TableActions.vue'
|
||||||
|
|
||||||
interface LevelRow { id:string; enterpriseLevel:string; scoreFrom:number; scoreTo:number; createTime:string }
|
interface LevelRow { id:string; enterpriseLevel:string; levelCode:string; scoreFrom:number; scoreTo:number; createTime:string }
|
||||||
const ENDPOINTS = [
|
const ENDPOINTS = [
|
||||||
'/user/enterpriseLevel/page',
|
'/user/enterpriseLevel/page',
|
||||||
'/user/enterpriseLevel/list'
|
'/user/enterpriseLevel/list'
|
||||||
]
|
]
|
||||||
const FALLBACK: LevelRow[] = [
|
const FALLBACK: LevelRow[] = [
|
||||||
{ id:'L001', enterpriseLevel:'A (战略级)', scoreFrom:90, scoreTo:100, createTime:'2026-01-05 10:00:00' },
|
{ id:'L001', enterpriseLevel:'A 战略级', levelCode:'A', scoreFrom:90, scoreTo:100, createTime:'2026-01-05 10:00:00' },
|
||||||
{ id:'L002', enterpriseLevel:'B (核心级)', scoreFrom:80, scoreTo:89, createTime:'2026-01-05 10:05:00' },
|
{ id:'L002', enterpriseLevel:'B 核心级', levelCode:'B', scoreFrom:80, scoreTo:89, createTime:'2026-01-05 10:05:00' },
|
||||||
{ id:'L003', enterpriseLevel:'C (成长级)', scoreFrom:70, scoreTo:79, createTime:'2026-01-05 10:10:00' },
|
{ id:'L003', enterpriseLevel:'C 成长级', levelCode:'C', scoreFrom:70, scoreTo:79, createTime:'2026-01-05 10:10:00' },
|
||||||
{ id:'L004', enterpriseLevel:'D (入门级)', scoreFrom:60, scoreTo:69, createTime:'2026-01-05 10:15:00' },
|
{ id:'L004', enterpriseLevel:'D 入门级', levelCode:'D', scoreFrom:60, scoreTo:69, createTime:'2026-01-05 10:15:00' },
|
||||||
{ id:'L005', enterpriseLevel:'E (观察级)', scoreFrom:0, scoreTo:59, createTime:'2026-01-05 10:20:00' }
|
{ id:'L005', enterpriseLevel:'E 观察级', levelCode:'E', scoreFrom:0, scoreTo:59, createTime:'2026-01-05 10:20:00' }
|
||||||
]
|
]
|
||||||
type Col = { type?: 'status'|'text'|'selection'; prop?: string; label: string; width?: number; slot?: string; fixed?: 'right'|true }
|
type Col = { prop?: string; label: string; minWidth?: number; width?: number; slot?: string; fixed?: 'right'; align?: 'center' | 'left' | 'right' }
|
||||||
const columns: Col[] = [
|
const columns: Col[] = [
|
||||||
{ type:'selection', label:'', width:50 },
|
{ prop: 'enterpriseLevel', label: '等级', minWidth: 160 },
|
||||||
{ prop:'id', label:'ID', width:80, fixed:true },
|
{ prop: 'scoreFrom', label: '起始分', minWidth: 110, align: 'center' },
|
||||||
{ prop:'enterpriseLevel', label:'等级', width:140 },
|
{ prop: 'scoreTo', label: '截止分', minWidth: 110, align: 'center' },
|
||||||
{ prop:'scoreFrom', label:'起始分', width:100 },
|
{ prop: 'createTime', label: '创建时间', minWidth: 180 },
|
||||||
{ prop:'scoreTo', label:'截止分', width:100 },
|
{ label: '操作', width: 180, slot: 'actions', fixed: 'right', align: 'center' }
|
||||||
{ prop:'actions', label:'操作', width:140, slot:'actions', fixed:'right' }
|
|
||||||
]
|
]
|
||||||
const STATUS_DEFAULTS = [
|
const list = ref<LevelRow[]>([]); const total = ref(0); const loading = ref(false); const page = ref(1); const limit = ref(10); const selection = ref<LevelRow[]>([])
|
||||||
{ value:'', label:'全部' }
|
const filterKw = ref(''); const filterLevel = ref('')
|
||||||
]
|
|
||||||
const list = ref<LevelRow[]>([]); const total = ref(0); const loading = ref(false); const page = ref(1); const limit = ref(10); const filterKw = ref(''); const statusTab = ref('')
|
|
||||||
const dataSource = ref<'api'|'mock'|'loading'>('loading'); const apiError = ref<string>('')
|
const dataSource = ref<'api'|'mock'|'loading'>('loading'); const apiError = ref<string>('')
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true; dataSource.value = 'loading'
|
loading.value = true; dataSource.value = 'loading'
|
||||||
for (const ep of ENDPOINTS) {
|
for (const ep of ENDPOINTS) {
|
||||||
@ -51,56 +47,152 @@ async function load() {
|
|||||||
}
|
}
|
||||||
list.value = [...FALLBACK]; total.value = FALLBACK.length; dataSource.value = 'mock'; apiError.value = '后端 API 暂未实装, 已加载前端示例数据'; loading.value = false
|
list.value = [...FALLBACK]; total.value = FALLBACK.length; dataSource.value = 'mock'; apiError.value = '后端 API 暂未实装, 已加载前端示例数据'; loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = computed(() => {
|
const filtered = computed(() => {
|
||||||
const kw = filterKw.value.trim().toLowerCase(); let rs = list.value
|
const kw = filterKw.value.trim().toLowerCase(); let rs = list.value
|
||||||
if (kw) rs = rs.filter(r => String(r.id).includes(kw) || String(r.enterpriseLevel).includes(kw))
|
if (filterLevel.value) rs = rs.filter(r => r.levelCode === filterLevel.value)
|
||||||
|
if (kw) rs = rs.filter(r => String(r.id).toLowerCase().includes(kw) || String(r.enterpriseLevel).toLowerCase().includes(kw))
|
||||||
return rs
|
return rs
|
||||||
})
|
})
|
||||||
const searchFields: Array<{ key: string; label: string; type: 'text' }> = [
|
|
||||||
{ key:'id', label:'ID', type:'text' },
|
const filters = reactive<Record<string, any>>({ id: '', enterpriseLevel: '' })
|
||||||
{ key:'enterpriseLevel', label:'等级', type:'text' }
|
|
||||||
]
|
function onSearch() {
|
||||||
const filters = reactive<Record<string, any>>({})
|
// 等级名称优先, 否则按 ID 检索 (原实现拼 'a|b' 整串 includes 永不匹配 2026-10-16 修)
|
||||||
function onSearch() { filterKw.value = String(filters.id || filters.enterpriseLevel || ''); page.value = 1 }
|
filterKw.value = (filters.enterpriseLevel || '').trim() || (filters.id || '').trim()
|
||||||
function onStatusTab(v: string) { statusTab.value = v; page.value = 1 }
|
page.value = 1
|
||||||
function onView(row: LevelRow) { ElMessage.info(`查看 ${row.id}`) }
|
|
||||||
async function onEdit(row: LevelRow) {
|
|
||||||
ElMessage.success('编辑成功 (前端示例)')
|
|
||||||
}
|
}
|
||||||
async function onDelete(row: LevelRow) {
|
function onReset() {
|
||||||
try { await ElMessageBox.confirm(`确认删除等级「${row.enterpriseLevel}」?`, '提示', { type: 'warning' }) } catch { return }
|
filters.id = ''; filters.enterpriseLevel = ''
|
||||||
ElMessage.success('已删除 (前端示例)')
|
filterKw.value = ''; page.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelectionChange(rows: LevelRow[]) { selection.value = rows }
|
||||||
|
|
||||||
|
function onView(row: LevelRow) { ElMessage.info(`查看等级 ${row.enterpriseLevel}`) }
|
||||||
|
function onEdit(row: LevelRow) { editVisible.value = true; editForm.id = row.id; editForm.enterpriseLevel = row.enterpriseLevel; editForm.scoreFrom = row.scoreFrom; editForm.scoreTo = row.scoreTo }
|
||||||
|
function onDelete(row: LevelRow) {
|
||||||
|
ElMessageBox.confirm(`确认删除等级「${row.enterpriseLevel}」?`, '删除确认', { type: 'warning' }).then(() => {
|
||||||
|
ElMessage.success('已删除 (前端示例)')
|
||||||
|
load()
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
async function onBatchDelete() {
|
||||||
|
if (!selection.value.length) return
|
||||||
|
await ElMessageBox.confirm(`确认批量删除选中的 ${selection.value.length} 个等级?`, '批量删除', { type: 'warning' }).catch(() => {})
|
||||||
|
ElMessage.success(`已批量删除 ${selection.value.length} 条 (前端示例)`)
|
||||||
|
selection.value = []
|
||||||
}
|
}
|
||||||
function onExport() { ElMessage.success('已发起导出 (前端示例, 共 ' + filtered.value.length + ' 条)') }
|
function onExport() { ElMessage.success('已发起导出 (前端示例, 共 ' + filtered.value.length + ' 条)') }
|
||||||
|
|
||||||
|
// 新增/编辑 弹窗
|
||||||
|
const editVisible = ref(false); const editMode = ref<'add'|'edit'>('add')
|
||||||
|
const editForm = reactive({ id: '', enterpriseLevel: '', levelCode: 'A', scoreFrom: 90, scoreTo: 100 })
|
||||||
|
function onAdd() { editMode.value = 'add'; editForm.id = ''; editForm.enterpriseLevel = ''; editForm.levelCode = 'A'; editForm.scoreFrom = 90; editForm.scoreTo = 100; editVisible.value = true }
|
||||||
|
async function onSubmitEdit() {
|
||||||
|
if (!editForm.enterpriseLevel.trim()) { ElMessage.warning('请填写等级名称'); return }
|
||||||
|
if (editForm.scoreFrom > editForm.scoreTo) { ElMessage.warning('起始分不能大于截止分'); return }
|
||||||
|
ElMessage.success((editMode.value === 'add' ? '新增' : '修改') + '成功 (前端示例)')
|
||||||
|
editVisible.value = false
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="rr-page">
|
<div class="rr-page">
|
||||||
<PageHeader title="企业等级管理" sub="供应商等级与对应分数段配置" :data-source="dataSource" :api-error="apiError">
|
<PageHeader title="企业等级管理" sub="供应商等级与对应分数段配置" :data-source="dataSource" :api-error="apiError" />
|
||||||
<el-button type="primary" @click="onExport">导出明细</el-button>
|
|
||||||
</PageHeader>
|
<!-- 工具行: 搜索 + 查询/重置 + 导出/新增 同排统一尺寸 (2026-10-16) -->
|
||||||
<el-tabs v-model="statusTab" @tab-change="onStatusTab" class="rr-tabs">
|
<div class="toolbar-row">
|
||||||
<el-tab-pane v-for="t in STATUS_DEFAULTS" :key="t.value" :name="t.value" :label="t.label" />
|
<el-input v-model="filters.enterpriseLevel" placeholder="如 A 战略级" clearable class="toolbar-input" @keyup.enter="onSearch" />
|
||||||
</el-tabs>
|
<el-input v-model="filters.id" placeholder="如 L001" clearable class="toolbar-input" @keyup.enter="onSearch" />
|
||||||
<SearchBar v-model:filters="filters" :fields="searchFields" @search="onSearch" />
|
<div class="toolbar-actions">
|
||||||
<DataTable :columns="columns" :data="filtered"
|
<el-button @click="onReset">重置</el-button>
|
||||||
|
<el-button type="primary" @click="onSearch">查询</el-button>
|
||||||
|
<span class="toolbar-divider"></span>
|
||||||
|
<el-button @click="onExport">导出明细</el-button>
|
||||||
|
<el-button type="primary" @click="onAdd">
|
||||||
|
<el-icon style="margin-right:4px"><Plus /></el-icon>新增等级
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rr-list-card">
|
||||||
|
<!-- 批量操作栏 -->
|
||||||
|
<div class="batch-bar" :class="{ 'has-selection': selection.length > 0 }">
|
||||||
|
<span v-if="selection.length > 0" class="batch-count">已选 <strong>{{ selection.length }}</strong> 个等级</span>
|
||||||
|
<span v-else class="batch-hint">勾选后可批量删除</span>
|
||||||
|
<div class="batch-actions">
|
||||||
|
<el-button size="small" type="danger" plain :disabled="!selection.length" @click="onBatchDelete">批量删除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable :columns="columns" :data="filtered"
|
||||||
:borderless="true" :loading="loading" :page="page" :limit="limit" :total="total" row-key="id"
|
:borderless="true" :loading="loading" :page="page" :limit="limit" :total="total" row-key="id"
|
||||||
@page-change="(p: number) => { page = p; load() }" @limit-change="(l: number) => { limit = l; page = 1; load() }">
|
@selection-change="onSelectionChange"
|
||||||
<template #actions="{ row }">
|
@page-change="(p: number) => { page = p; load() }"
|
||||||
<TableActions
|
@limit-change="(l: number) => { limit = l; page = 1; load() }">
|
||||||
:row="row"
|
<template #actions="{ row }">
|
||||||
:actions="[
|
<TableActions
|
||||||
{ label: '查看', type: 'primary', onClick: onView },
|
:row="row"
|
||||||
{ label: '编辑', type: 'primary', onClick: onEdit },
|
:actions="[
|
||||||
{ label: '删除', type: 'danger', onClick: onDelete }
|
{ label: '编辑', type: 'primary', onClick: onEdit },
|
||||||
]"
|
{ label: '删除', type: 'danger', divided: true, onClick: onDelete }
|
||||||
/>
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 新增/编辑 弹窗 -->
|
||||||
|
<el-dialog v-model="editVisible" :title="editMode === 'add' ? '新增等级' : '修改等级'" width="500px" :close-on-click-modal="false" destroy-on-close>
|
||||||
|
<el-form label-width="100px" label-position="right">
|
||||||
|
<el-form-item label="等级名称" required>
|
||||||
|
<el-input v-model="editForm.enterpriseLevel" placeholder="如 A 战略级" maxlength="30" show-word-limit clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="等级代码" required>
|
||||||
|
<el-select v-model="editForm.levelCode" style="width:100%">
|
||||||
|
<el-option v-for="c in ['A','B','C','D','E']" :key="c" :value="c" :label="`${c} 级`" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="起始分" required>
|
||||||
|
<el-input-number v-model="editForm.scoreFrom" :min="0" :max="100" :step="1" style="width:100%" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="截止分" required>
|
||||||
|
<el-input-number v-model="editForm.scoreTo" :min="0" :max="100" :step="1" style="width:100%" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="editVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="onSubmitEdit">确定</el-button>
|
||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
|
||||||
|
<style scoped lang="scss">
|
||||||
.rr-page { padding: 16px; }
|
.rr-page { padding: 16px; }
|
||||||
.rr-tabs { margin-bottom: 12px; }
|
|
||||||
|
/* 工具行: 搜索与操作按钮同排, 统一默认尺寸 */
|
||||||
|
.toolbar-row {
|
||||||
|
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.toolbar-input { width: 200px; }
|
||||||
|
.toolbar-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex; align-items: center; gap: 10px;
|
||||||
|
:deep(.el-button + .el-button) { margin-left: 0; }
|
||||||
|
}
|
||||||
|
.toolbar-divider {
|
||||||
|
width: 1px; height: 18px; background: var(--border-strong, #cbd5e1);
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
/* 表头与数据统一居中 (覆盖 DataTable 组件 inline 的表头居中/数据左对齐 2026-10-16) */
|
||||||
|
.rr-list-card :deep(.el-table th.el-table__cell),
|
||||||
|
.rr-list-card :deep(.el-table td.el-table__cell) {
|
||||||
|
text-align: center !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,117 +1,233 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* F006 供应商评级管理 - W16
|
* F006 供应商评级管理 - 2026-08-22 UI 整改
|
||||||
*
|
*
|
||||||
* 沿用老 vue user/enterprise/enterprise-rating.vue 字段
|
* 后端端点: GET /user/enterprise/getRatingPage (RatingVO 返回)
|
||||||
|
* 参考老后台: /vue/statistics/index-town#/user/enterprise/enterprise-rating
|
||||||
*
|
*
|
||||||
* 端点: 4 URL 探测 (enterpriserating/page, /list, /search, /queryPage); 404 → FALLBACK seed
|
* 老后台搜索字段: 企业id / 企业名称 / 手机号 / 行业类型 / 账号状态 / 供应商等级
|
||||||
|
* 老后台列: 供应商id / 供应商信息 / 等级 / 客户评价 / 履约评价 / 奖惩 / 最终 / 评级时间
|
||||||
*/
|
*/
|
||||||
import { ref, reactive, onMounted, computed } from 'vue'
|
import { ref, reactive, onMounted, computed } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { httpGet } from '@/utils/request'
|
import { httpGet } from '@/utils/request'
|
||||||
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||||
import SearchBar from '@/components/SearchBar/SearchBar.vue'
|
|
||||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||||
|
import TableActions from '@/components/TableActions/TableActions.vue'
|
||||||
|
|
||||||
|
interface RatingRow {
|
||||||
|
id: string
|
||||||
|
enterpriseName: string
|
||||||
|
industry?: string
|
||||||
|
supplierLevel: string
|
||||||
|
clientScore: number
|
||||||
|
performanceScore: number
|
||||||
|
awardScore: number
|
||||||
|
finalScore: number
|
||||||
|
deadline: string
|
||||||
|
}
|
||||||
|
|
||||||
interface RatingRow { id:string; enterpriseId:string; enterpriseName:string; supplierLevel:string; clientScore:number; performanceScore:number; awardScore:number; finalScore:number; deadline:string }
|
|
||||||
const ENDPOINTS = [
|
const ENDPOINTS = [
|
||||||
'/user/performancescorerecord/page',
|
'/user/enterprise/getRatingPage',
|
||||||
'/user/performancescorerecord/list'
|
'/user/enterprise/ratingExport'
|
||||||
]
|
]
|
||||||
|
|
||||||
const FALLBACK: RatingRow[] = [
|
const FALLBACK: RatingRow[] = [
|
||||||
{ id:'R001', enterpriseId:'s20015', enterpriseName:'坚美建筑材料有限公司', supplierLevel:'A 战略级', clientScore:96, performanceScore:92, awardScore:5, finalScore:193, deadline:'2026-06-30 23:59:59' },
|
{ id:'R001', enterpriseName:'坚美建筑材料有限公司', industry:'建材', supplierLevel:'A 战略级', clientScore:96, performanceScore:92, awardScore:5, finalScore:193, deadline:'2026-06-30 23:59:59' },
|
||||||
{ id:'R002', enterpriseId:'s20016', enterpriseName:'华建门窗厂', supplierLevel:'B 核心级', clientScore:88, performanceScore:85, awardScore:3, finalScore:176, deadline:'2026-06-30 23:59:59' },
|
{ id:'R002', enterpriseName:'华建门窗厂', industry:'门窗', supplierLevel:'B 核心级', clientScore:88, performanceScore:85, awardScore:3, finalScore:176, deadline:'2026-06-30 23:59:59' },
|
||||||
{ id:'R003', enterpriseId:'s20017', enterpriseName:'融鑫管业', supplierLevel:'B 核心级', clientScore:84, performanceScore:88, awardScore:0, finalScore:172, deadline:'2026-06-30 23:59:59' },
|
{ id:'R003', enterpriseName:'融鑫管业', industry:'管业', supplierLevel:'B 核心级', clientScore:84, performanceScore:88, awardScore:0, finalScore:172, deadline:'2026-06-30 23:59:59' }
|
||||||
{ id:'R004', enterpriseId:'s20018', enterpriseName:'东方五金机械', supplierLevel:'A 战略级', clientScore:92, performanceScore:90, awardScore:8, finalScore:190, deadline:'2026-06-30 23:59:59' },
|
|
||||||
{ id:'R005', enterpriseId:'s20019', enterpriseName:'美洁涂料', supplierLevel:'C 成长级', clientScore:78, performanceScore:76, awardScore:-2, finalScore:152, deadline:'2026-06-30 23:59:59' },
|
|
||||||
{ id:'R006', enterpriseId:'s20020', enterpriseName:'万家灯具', supplierLevel:'B 核心级', clientScore:86, performanceScore:82, awardScore:2, finalScore:170, deadline:'2026-06-30 23:59:59' },
|
|
||||||
{ id:'R007', enterpriseId:'s20021', enterpriseName:'顺达物流', supplierLevel:'D 入门级', clientScore:65, performanceScore:68, awardScore:0, finalScore:133, deadline:'2026-06-30 23:59:59' },
|
|
||||||
{ id:'R008', enterpriseId:'s20022', enterpriseName:'众成厨具', supplierLevel:'C 成长级', clientScore:75, performanceScore:72, awardScore:-3, finalScore:144, deadline:'2026-06-30 23:59:59' },
|
|
||||||
{ id:'R009', enterpriseId:'s20023', enterpriseName:'坚美建筑材料', supplierLevel:'A 战略级', clientScore:94, performanceScore:91, awardScore:6, finalScore:191, deadline:'2026-06-30 23:59:59' },
|
|
||||||
{ id:'R010', enterpriseId:'s20024', enterpriseName:'华建门窗厂', supplierLevel:'A 战略级', clientScore:90, performanceScore:89, awardScore:4, finalScore:183, deadline:'2026-06-30 23:59:59' },
|
|
||||||
{ id:'R011', enterpriseId:'s20025', enterpriseName:'融鑫管业', supplierLevel:'C 成长级', clientScore:74, performanceScore:78, awardScore:0, finalScore:152, deadline:'2026-06-30 23:59:59' },
|
|
||||||
{ id:'R012', enterpriseId:'s20026', enterpriseName:'美洁涂料', supplierLevel:'D 入门级', clientScore:62, performanceScore:65, awardScore:-1, finalScore:126, deadline:'2026-06-30 23:59:59' }
|
|
||||||
]
|
]
|
||||||
type Col = { type?: 'status'|'text'|'selection'; prop?: string; label: string; width?: number; slot?: string; fixed?: 'right'|true }
|
|
||||||
|
type Col = { prop?: string; label: string; minWidth?: number; width?: number; slot?: string; fixed?: 'right'; align?: 'center' | 'left' | 'right' }
|
||||||
const columns: Col[] = [
|
const columns: Col[] = [
|
||||||
{ type:'selection', label:'', width:50 },
|
{ prop: 'enterpriseName', label: '供应商', minWidth: 180 },
|
||||||
{ prop:'id', label:'评级ID', width:90, fixed:true },
|
{ prop: 'supplierLevel', label: '供应商等级', minWidth: 110, slot: 'level', align: 'center' },
|
||||||
{ prop:'enterpriseName', label:'供应商', width:200 },
|
{ prop: 'industry', label: '所属行业', minWidth: 120, align: 'center' },
|
||||||
{ prop:'supplierLevel', label:'等级', width:120, slot:'level' },
|
{ prop: 'clientScore', label: '客户评价', minWidth: 100, align: 'center' },
|
||||||
{ prop:'clientScore', label:'客户评价', width:100 },
|
{ prop: 'performanceScore', label: '履约评价', minWidth: 100, align: 'center' },
|
||||||
{ prop:'performanceScore', label:'履约评价', width:100 },
|
{ prop: 'awardScore', label: '奖惩加减分', minWidth: 110, slot: 'award', align: 'center' },
|
||||||
{ prop:'awardScore', label:'奖惩分', width:90, slot:'award' },
|
{ prop: 'finalScore', label: '最终得分', minWidth: 110, slot: 'final', align: 'center' },
|
||||||
{ prop:'finalScore', label:'最终得分', width:100, slot:'final' },
|
{ prop: 'deadline', label: '评级时间', minWidth: 170 },
|
||||||
{ prop:'deadline', label:'评级时间', width:170, slot:'deadline' },
|
{ label: '操作', width: 200, slot: 'actions', fixed: 'right', align: 'center' }
|
||||||
{ prop:'actions', label:'操作', width:180, slot:'actions', fixed:'right' }
|
|
||||||
]
|
]
|
||||||
const STATUS_DEFAULTS = [
|
|
||||||
{ value:'', label:'全部' }
|
const list = ref<RatingRow[]>([]); const total = ref(0); const loading = ref(false); const page = ref(1); const limit = ref(10); const selection = ref<RatingRow[]>([])
|
||||||
]
|
const filterKw = ref('')
|
||||||
const list = ref<RatingRow[]>([]); const total = ref(0); const loading = ref(false); const page = ref(1); const limit = ref(10); const filterKw = ref(''); const statusTab = ref('')
|
const dataSource = ref<'api' | 'mock' | 'loading'>('loading'); const apiError = ref<string>('')
|
||||||
const dataSource = ref<'api'|'mock'|'loading'>('loading'); const apiError = ref<string>('')
|
|
||||||
|
// inline 表单搜索字段 (label 在输入框左侧)
|
||||||
|
const searchFields = reactive({
|
||||||
|
enterpriseName: '',
|
||||||
|
linkMobile: '',
|
||||||
|
supplierLevel: ''
|
||||||
|
})
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true; dataSource.value = 'loading'
|
loading.value = true; dataSource.value = 'loading'
|
||||||
for (const ep of ENDPOINTS) {
|
for (const ep of ENDPOINTS) {
|
||||||
try {
|
try {
|
||||||
const data: any = await httpGet<any>(ep, { page: page.value, limit: limit.value })
|
const data: any = await httpGet<any>(ep, { page: page.value, limit: limit.value, ...searchFields })
|
||||||
const rows = (data?.list || []).map((it: any) => ({ ...it, raw: it }))
|
const rows = (data?.list || data?.data?.list || []).map((it: any) => ({
|
||||||
list.value = rows; total.value = Number(data?.total || rows.length); dataSource.value = 'api'; apiError.value = ''; loading.value = false; return
|
id: String(it.id ?? ''),
|
||||||
|
enterpriseName: it.enterpriseName || '-',
|
||||||
|
industry: it.industry || '-',
|
||||||
|
supplierLevel: it.supplierLevel || '-',
|
||||||
|
clientScore: Number(it.clientScore) || 0,
|
||||||
|
performanceScore: Number(it.performanceScore) || 0,
|
||||||
|
awardScore: Number(it.awardScore) || 0,
|
||||||
|
finalScore: Number(it.finalScore) || 0,
|
||||||
|
deadline: it.deadline || ''
|
||||||
|
}))
|
||||||
|
list.value = rows
|
||||||
|
total.value = Number(data?.total || data?.data?.total || rows.length)
|
||||||
|
dataSource.value = 'api'; apiError.value = ''
|
||||||
|
loading.value = false; return
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
list.value = [...FALLBACK]; total.value = FALLBACK.length; dataSource.value = 'mock'; apiError.value = '后端 API 暂未实装, 已加载前端示例数据'; loading.value = false
|
// 所有端点失败, fallback
|
||||||
|
list.value = FALLBACK
|
||||||
|
total.value = FALLBACK.length
|
||||||
|
dataSource.value = 'mock'
|
||||||
|
apiError.value = '后端 API 暂未实装, 已加载前端示例数据'
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = computed(() => {
|
const filtered = computed(() => {
|
||||||
const kw = filterKw.value.trim().toLowerCase(); let rs = list.value
|
const kw = filterKw.value.trim().toLowerCase()
|
||||||
if (kw) rs = rs.filter(r => String(r.enterpriseName).includes(kw))
|
let rs = list.value
|
||||||
|
if (kw) rs = rs.filter(r => String(r.enterpriseName).toLowerCase().includes(kw))
|
||||||
return rs
|
return rs
|
||||||
})
|
})
|
||||||
const searchFields: Array<{ key: string; label: string; type: 'text' }> = [
|
|
||||||
{ key:'enterpriseName', label:'供应商', type:'text' }
|
function onSelectionChange(rows: RatingRow[]) { selection.value = rows }
|
||||||
]
|
function onSearch() { page.value = 1; load() }
|
||||||
const filters = reactive<Record<string, any>>({})
|
function onReset() { searchFields.enterpriseName = ''; searchFields.linkMobile = ''; searchFields.supplierLevel = ''; filterKw.value = ''; page.value = 1 }
|
||||||
function onSearch() { filterKw.value = String(filters.enterpriseName || ''); page.value = 1 }
|
|
||||||
function onStatusTab(v: string) { statusTab.value = v; page.value = 1 }
|
function onView(row: RatingRow) { ElMessage.info(`详情 ${row.enterpriseName}`) }
|
||||||
function onView(row: RatingRow) { ElMessage.info(`详情 ${row.id}`) }
|
function onEdit(row: RatingRow) { ElMessage.success('已发起调整 (前端示例)') }
|
||||||
async function onEdit(row: RatingRow) {
|
async function onBatchExport() {
|
||||||
ElMessage.success('已发起调整 (前端示例)')
|
await ElMessageBox.confirm(`确认导出选中的 ${selection.value.length} 条评级?`, '批量导出', { type: 'info' }).catch(() => {})
|
||||||
|
ElMessage.success(`已发起批量导出 (前端示例, 共 ${selection.value.length} 条)`)
|
||||||
}
|
}
|
||||||
function onExport() { ElMessage.success('已发起导出 (前端示例, 共 ' + filtered.value.length + ' 条)') }
|
function onExport() { ElMessage.success(`已发起导出 (前端示例, 共 ${list.value.length} 条)`) }
|
||||||
|
|
||||||
|
function levelPill(r: RatingRow) {
|
||||||
|
const lvl = r.supplierLevel || ''
|
||||||
|
if (lvl.startsWith('A')) return { color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' }
|
||||||
|
if (lvl.startsWith('B')) return { color: 'var(--brand)', bg: 'var(--brand-soft)' }
|
||||||
|
if (lvl.startsWith('C')) return { color: 'var(--st-warn-fg)', bg: 'var(--st-warn-bg)' }
|
||||||
|
return { color: 'var(--st-err-fg)', bg: 'var(--st-err-bg)' }
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="rr-page">
|
<div class="rr-page">
|
||||||
<PageHeader title="供应商评级管理" sub="供应商基于多维度打分的最终评级" :data-source="dataSource" :api-error="apiError">
|
<PageHeader title="供应商评级管理" sub="供应商基于多维度打分的最终评级" :data-source="dataSource" :api-error="apiError">
|
||||||
<el-button type="primary" @click="onExport">导出明细</el-button>
|
<el-button @click="onExport">导出明细</el-button>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<el-tabs v-model="statusTab" @tab-change="onStatusTab" class="rr-tabs">
|
|
||||||
<el-tab-pane v-for="t in STATUS_DEFAULTS" :key="t.value" :name="t.value" :label="t.label" />
|
<!-- 内联搜索栏 (label 在输入框左侧) -->
|
||||||
</el-tabs>
|
<div class="rr-list-card">
|
||||||
<SearchBar v-model:filters="filters" :fields="searchFields" @search="onSearch" />
|
<el-form :inline="true" class="rr-search-inline">
|
||||||
<DataTable :columns="columns" :data="filtered"
|
<el-form-item label="供应商名称">
|
||||||
:borderless="true" :loading="loading" :page="page" :limit="limit" :total="total" row-key="id"
|
<el-input v-model="searchFields.enterpriseName" placeholder="请输入供应商名称" clearable style="width:200px" @keyup.enter="onSearch" />
|
||||||
@page-change="(p: number) => { page = p; load() }" @limit-change="(l: number) => { limit = l; page = 1; load() }">
|
</el-form-item>
|
||||||
<template #level="{ row }">
|
<el-form-item label="手机号">
|
||||||
<el-tag :type="row.supplierLevel.startsWith('A') ? 'success' : row.supplierLevel.startsWith('B') ? 'primary' : row.supplierLevel.startsWith('C') ? 'warning' : 'info'" size="small">{{ row.supplierLevel }}</el-tag>
|
<el-input v-model="searchFields.linkMobile" placeholder="请输入手机号" clearable style="width:160px" @keyup.enter="onSearch" />
|
||||||
</template>
|
</el-form-item>
|
||||||
<template #award="{ row }">
|
<el-form-item label="供应商等级">
|
||||||
<span :style="{ color: row.awardScore > 0 ? '#67c23a' : row.awardScore < 0 ? '#f56c6c' : '#999' }">
|
<el-input v-model="searchFields.supplierLevel" placeholder="如 A 战略级" clearable style="width:140px" @keyup.enter="onSearch" />
|
||||||
{{ row.awardScore > 0 ? '+' : '' }}{{ row.awardScore }}
|
</el-form-item>
|
||||||
</span>
|
<el-form-item>
|
||||||
</template>
|
<el-button type="primary" @click="onSearch">查询</el-button>
|
||||||
<template #final="{ row }">
|
<el-button @click="onReset">重置</el-button>
|
||||||
<span style="font-weight: 600;">{{ row.finalScore }}</span>
|
</el-form-item>
|
||||||
</template>
|
</el-form>
|
||||||
<template #deadline="{ row }">{{ row.deadline }}</template>
|
|
||||||
<template #actions="{ row }">
|
<!-- 批量操作栏 -->
|
||||||
<el-button type="primary" link @click="onView(row)">详情</el-button>
|
<div class="batch-bar" :class="{ 'has-selection': selection.length > 0 }">
|
||||||
<el-button type="success" link @click="onEdit(row)">调整分</el-button>
|
<span v-if="selection.length > 0" class="batch-count">已选 <strong>{{ selection.length }}</strong> 条评级</span>
|
||||||
</template>
|
<span v-else class="batch-hint">勾选后可批量导出</span>
|
||||||
</DataTable>
|
<div class="batch-actions">
|
||||||
|
<el-button size="small" plain :disabled="!selection.length" @click="onBatchExport">批量导出</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:columns="columns"
|
||||||
|
:data="list"
|
||||||
|
:loading="loading"
|
||||||
|
row-key="id"
|
||||||
|
borderless
|
||||||
|
empty-text="暂无评级"
|
||||||
|
:show-pagination="true"
|
||||||
|
:page="page"
|
||||||
|
:limit="limit"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
@selection-change="onSelectionChange"
|
||||||
|
@page-change="(p: number) => { page = p; load() }"
|
||||||
|
@limit-change="(l: number) => { limit = l; page = 1; load() }"
|
||||||
|
>
|
||||||
|
<template #level="{ row }">
|
||||||
|
<span class="st-pill" :style="{ color: levelPill(row).color, background: levelPill(row).bg }">
|
||||||
|
<span class="st-dot"></span><span class="truncate-text">{{ row.supplierLevel }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #award="{ row }">
|
||||||
|
<span :style="{ color: row.awardScore > 0 ? 'var(--green)' : row.awardScore < 0 ? 'var(--red)' : 'var(--ink-3)', fontWeight: 600 }">
|
||||||
|
{{ row.awardScore > 0 ? '+' : '' }}{{ row.awardScore }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #final="{ row }">
|
||||||
|
<span style="font-weight: 700; color: var(--ink);">{{ row.finalScore }}</span>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<TableActions
|
||||||
|
:row="row"
|
||||||
|
:actions="[
|
||||||
|
{ label: '详情', type: 'primary', onClick: onView },
|
||||||
|
{ label: '调整分', type: 'success', onClick: onEdit }
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
|
||||||
|
<style scoped lang="scss">
|
||||||
.rr-page { padding: 16px; }
|
.rr-page { padding: 16px; }
|
||||||
.rr-tabs { margin-bottom: 12px; }
|
|
||||||
|
/* inline 搜索栏: label 在输入框左侧, 与查询按钮同行 */
|
||||||
|
.rr-search-inline {
|
||||||
|
padding: 12px 16px 0;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0;
|
||||||
|
|
||||||
|
:deep(.el-form-item) {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
margin-right: 16px;
|
||||||
|
}
|
||||||
|
:deep(.el-form-item__label) {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ink-2);
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 长文本截断 (level pill 文本过长) */
|
||||||
|
.truncate-text {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 90px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* F006 供应商评分管理 - W16
|
* F006 供应商评分管理 - W16 + UI 审计补完 (2026-08-22)
|
||||||
*
|
|
||||||
* 沿用老 vue user/enterprise/enterprise-score.vue 字段
|
|
||||||
*
|
*
|
||||||
* 端点: 4 URL 探测 (enterprisescore/page, /list, /search, /queryPage); 404 → FALLBACK seed
|
* 端点: 4 URL 探测 (enterprisescore/page, /list, /search, /queryPage); 404 → FALLBACK seed
|
||||||
*/
|
*/
|
||||||
@ -10,8 +8,9 @@ import { ref, reactive, onMounted, computed } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { httpGet } from '@/utils/request'
|
import { httpGet } from '@/utils/request'
|
||||||
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
||||||
import SearchBar from '@/components/SearchBar/SearchBar.vue'
|
import SearchBar, { type SearchField } from '@/components/SearchBar/SearchBar.vue'
|
||||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||||
|
import TableActions from '@/components/TableActions/TableActions.vue'
|
||||||
|
|
||||||
interface ScoreRow { id:string; enterpriseId:string; enterpriseName:string; scoreContent:string; evaluator:string; score:number; deadline:string; status:number }
|
interface ScoreRow { id:string; enterpriseId:string; enterpriseName:string; scoreContent:string; evaluator:string; score:number; deadline:string; status:number }
|
||||||
const ENDPOINTS = [
|
const ENDPOINTS = [
|
||||||
@ -19,36 +18,31 @@ const ENDPOINTS = [
|
|||||||
'/user/performancescoredetail/list'
|
'/user/performancescoredetail/list'
|
||||||
]
|
]
|
||||||
const FALLBACK: ScoreRow[] = [
|
const FALLBACK: ScoreRow[] = [
|
||||||
{ id:'SC001', enterpriseId:'s20015', enterpriseName:'坚美建筑材料有限公司', scoreContent:'商品质量', evaluator:'张工长', score:96, deadline:'2026-07-10 23:59:59', status:1 },
|
{ id:'SC001', enterpriseId:'s20015', enterpriseName:'坚美建筑材料有限公司', scoreContent:'商品质量', evaluator:'张工长', score:96, deadline:'2026-07-10 23:59:59', status:1 },
|
||||||
{ id:'SC002', enterpriseId:'s20015', enterpriseName:'坚美建筑材料有限公司', scoreContent:'配送时效', evaluator:'李采购', score:90, deadline:'2026-07-10 23:59:59', status:1 },
|
{ id:'SC002', enterpriseId:'s20015', enterpriseName:'坚美建筑材料有限公司', scoreContent:'配送时效', evaluator:'李采购', score:90, deadline:'2026-07-10 23:59:59', status:1 },
|
||||||
{ id:'SC003', enterpriseId:'s20016', enterpriseName:'华建门窗厂', scoreContent:'商品质量', evaluator:'王工长', score:88, deadline:'2026-07-10 23:59:59', status:1 },
|
{ id:'SC003', enterpriseId:'s20016', enterpriseName:'华建门窗厂', scoreContent:'商品质量', evaluator:'王工长', score:88, deadline:'2026-07-10 23:59:59', status:1 },
|
||||||
{ id:'SC004', enterpriseId:'s20016', enterpriseName:'华建门窗厂', scoreContent:'安装服务', evaluator:'赵监理', score:92, deadline:'2026-07-10 23:59:59', status:1 },
|
{ id:'SC004', enterpriseId:'s20016', enterpriseName:'华建门窗厂', scoreContent:'安装服务', evaluator:'赵监理', score:92, deadline:'2026-07-10 23:59:59', status:1 },
|
||||||
{ id:'SC005', enterpriseId:'s20017', enterpriseName:'融鑫管业', scoreContent:'商品质量', evaluator:'钱工', score:85, deadline:'2026-07-15 23:59:59', status:0 },
|
{ id:'SC005', enterpriseId:'s20017', enterpriseName:'融鑫管业', scoreContent:'商品质量', evaluator:'钱工', score:85, deadline:'2026-07-15 23:59:59', status:0 },
|
||||||
{ id:'SC006', enterpriseId:'s20018', enterpriseName:'东方五金机械', scoreContent:'商品质量', evaluator:'孙经理', score:94, deadline:'2026-07-15 23:59:59', status:0 },
|
{ id:'SC006', enterpriseId:'s20018', enterpriseName:'东方五金机械', scoreContent:'商品质量', evaluator:'孙经理', score:94, deadline:'2026-07-15 23:59:59', status:0 },
|
||||||
{ id:'SC007', enterpriseId:'s20019', enterpriseName:'美洁涂料', scoreContent:'商品质量', evaluator:'周工', score:78, deadline:'2026-07-15 23:59:59', status:0 },
|
{ id:'SC007', enterpriseId:'s20019', enterpriseName:'美洁涂料', scoreContent:'商品质量', evaluator:'周工', score:78, deadline:'2026-07-15 23:59:59', status:0 },
|
||||||
{ id:'SC008', enterpriseId:'s20020', enterpriseName:'万家灯具', scoreContent:'商品质量', evaluator:'吴总', score:88, deadline:'2026-07-15 23:59:59', status:0 },
|
{ id:'SC008', enterpriseId:'s20020', enterpriseName:'万家灯具', scoreContent:'商品质量', evaluator:'吴总', score:88, deadline:'2026-07-15 23:59:59', status:0 },
|
||||||
{ id:'SC009', enterpriseId:'s20021', enterpriseName:'顺达物流', scoreContent:'配送时效', evaluator:'郑采购', score:65, deadline:'2026-07-20 23:59:59', status:0 },
|
{ id:'SC009', enterpriseId:'s20021', enterpriseName:'顺达物流', scoreContent:'配送时效', evaluator:'郑采购', score:65, deadline:'2026-07-20 23:59:59', status:0 },
|
||||||
{ id:'SC010', enterpriseId:'s20022', enterpriseName:'众成厨具', scoreContent:'商品质量', evaluator:'冯工', score:76, deadline:'2026-07-20 23:59:59', status:0 },
|
{ id:'SC010', enterpriseId:'s20022', enterpriseName:'众成厨具', scoreContent:'商品质量', evaluator:'冯工', score:76, deadline:'2026-07-20 23:59:59', status:0 }
|
||||||
{ id:'SC011', enterpriseId:'s20023', enterpriseName:'坚美建筑材料', scoreContent:'商品质量', evaluator:'陈工长', score:95, deadline:'2026-07-20 23:59:59', status:0 },
|
|
||||||
{ id:'SC012', enterpriseId:'s20024', enterpriseName:'华建门窗厂', scoreContent:'商品质量', evaluator:'褚采购', score:90, deadline:'2026-07-20 23:59:59', status:0 }
|
|
||||||
]
|
]
|
||||||
type Col = { type?: 'status'|'text'|'selection'; prop?: string; label: string; width?: number; slot?: string; fixed?: 'right'|true }
|
type Col = { prop?: string; label: string; minWidth?: number; width?: number; slot?: string; fixed?: 'right'; align?: 'center' | 'left' | 'right' }
|
||||||
const columns: Col[] = [
|
const columns: Col[] = [
|
||||||
{ type:'selection', label:'', width:50 },
|
{ prop: 'enterpriseName', label: '评分对象', minWidth: 180 },
|
||||||
{ prop:'id', label:'评分ID', width:90, fixed:true },
|
{ prop: 'scoreContent', label: '评分内容', minWidth: 130 },
|
||||||
{ prop:'enterpriseName', label:'评分对象', width:200 },
|
{ prop: 'evaluator', label: '评分员', minWidth: 120 },
|
||||||
{ prop:'scoreContent', label:'评分内容', width:140 },
|
{ prop: 'score', label: '分值', minWidth: 100, slot: 'score', align: 'center' },
|
||||||
{ prop:'evaluator', label:'评分员', width:120 },
|
{ prop: 'status', label: '状态', minWidth: 110, slot: 'status', align: 'center' },
|
||||||
{ prop:'score', label:'分值', width:90, slot:'score' },
|
{ prop: 'deadline', label: '截止时间', minWidth: 170 },
|
||||||
{ prop:'status', label:'状态', width:90, slot:'status' },
|
{ label: '操作', width: 200, slot: 'actions', fixed: 'right', align: 'center' }
|
||||||
{ prop:'deadline', label:'截止时间', width:170, slot:'deadline' },
|
|
||||||
{ prop:'actions', label:'操作', width:180, slot:'actions', fixed:'right' }
|
|
||||||
]
|
]
|
||||||
const STATUS_DEFAULTS = [
|
const list = ref<ScoreRow[]>([]); const total = ref(0); const loading = ref(false); const page = ref(1); const limit = ref(10); const selection = ref<ScoreRow[]>([])
|
||||||
{ value:'', label:'全部' }
|
const filterKw = ref('')
|
||||||
]
|
|
||||||
const list = ref<ScoreRow[]>([]); const total = ref(0); const loading = ref(false); const page = ref(1); const limit = ref(10); const filterKw = ref(''); const statusTab = ref('')
|
|
||||||
const dataSource = ref<'api'|'mock'|'loading'>('loading'); const apiError = ref<string>('')
|
const dataSource = ref<'api'|'mock'|'loading'>('loading'); const apiError = ref<string>('')
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true; dataSource.value = 'loading'
|
loading.value = true; dataSource.value = 'loading'
|
||||||
for (const ep of ENDPOINTS) {
|
for (const ep of ENDPOINTS) {
|
||||||
@ -60,53 +54,152 @@ async function load() {
|
|||||||
}
|
}
|
||||||
list.value = [...FALLBACK]; total.value = FALLBACK.length; dataSource.value = 'mock'; apiError.value = '后端 API 暂未实装, 已加载前端示例数据'; loading.value = false
|
list.value = [...FALLBACK]; total.value = FALLBACK.length; dataSource.value = 'mock'; apiError.value = '后端 API 暂未实装, 已加载前端示例数据'; loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = computed(() => {
|
const filtered = computed(() => {
|
||||||
const kw = filterKw.value.trim().toLowerCase(); let rs = list.value
|
const kw = filterKw.value.trim().toLowerCase(); let rs = list.value
|
||||||
if (kw) rs = rs.filter(r => String(r.enterpriseName).includes(kw) || String(r.evaluator).includes(kw))
|
if (kw) rs = rs.filter(r => String(r.enterpriseName).toLowerCase().includes(kw) || String(r.evaluator).toLowerCase().includes(kw) || String(r.scoreContent).toLowerCase().includes(kw))
|
||||||
return rs
|
return rs
|
||||||
})
|
})
|
||||||
const searchFields: Array<{ key: string; label: string; type: 'text' }> = [
|
|
||||||
{ key:'enterpriseName', label:'评分对象', type:'text' },
|
// 顶部统计
|
||||||
{ key:'evaluator', label:'评分员', type:'text' }
|
const stats = computed(() => {
|
||||||
|
const rs = filtered.value
|
||||||
|
const done = rs.filter(r => r.status === 1).length
|
||||||
|
const pending = rs.length - done
|
||||||
|
const avg = rs.filter(r => r.status === 1).length
|
||||||
|
? Math.round(rs.filter(r => r.status === 1).reduce((s, r) => s + (r.score || 0), 0) / rs.filter(r => r.status === 1).length)
|
||||||
|
: 0
|
||||||
|
return [
|
||||||
|
{ label: '评分记录', value: rs.length, icon: 'Document', color: 'var(--brand)' },
|
||||||
|
{ label: '已完成', value: done, icon: 'CircleCheck', color: 'var(--green)' },
|
||||||
|
{ label: '待评分', value: pending, icon: 'Bell', color: 'var(--amber)' },
|
||||||
|
{ label: '已完成平均分', value: avg, icon: 'DataAnalysis', color: 'var(--purple)' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const searchFields: SearchField[] = [
|
||||||
|
{ key: 'enterpriseName', label: '评分对象', type: 'text', placeholder: '搜索供应商' },
|
||||||
|
{ key: 'evaluator', label: '评分员', type: 'text', placeholder: '如 张工长' }
|
||||||
]
|
]
|
||||||
const filters = reactive<Record<string, any>>({})
|
const filters = reactive<Record<string, any>>({ enterpriseName: '', evaluator: '' })
|
||||||
function onSearch() { filterKw.value = String(filters.enterpriseName || filters.evaluator || ''); page.value = 1 }
|
|
||||||
function onStatusTab(v: string) { statusTab.value = v; page.value = 1 }
|
function onSearch() {
|
||||||
function onView(row: ScoreRow) { ElMessage.info(`详情 ${row.id}`) }
|
filterKw.value = `${filters.enterpriseName || ''}|${filters.evaluator || ''}`
|
||||||
async function onScore(row: ScoreRow) {
|
page.value = 1
|
||||||
ElMessage.success('已跳转打分 (前端示例)')
|
|
||||||
}
|
}
|
||||||
function onExport() { ElMessage.success('已发起导出 (前端示例, 共 ' + filtered.value.length + ' 条)') }
|
function onReset() { filters.enterpriseName = ''; filters.evaluator = ''; filterKw.value = ''; page.value = 1 }
|
||||||
|
|
||||||
|
function onSelectionChange(rows: ScoreRow[]) { selection.value = rows }
|
||||||
|
|
||||||
|
function onView(row: ScoreRow) { ElMessage.info(`详情 ${row.id}`) }
|
||||||
|
function onScore(row: ScoreRow) {
|
||||||
|
ElMessageBox.confirm(`确认给「${row.enterpriseName}」的「${row.scoreContent}」打分?`, '去打分', { type: 'info' }).then(() => {
|
||||||
|
ElMessage.success('已提交评分 (前端示例)')
|
||||||
|
row.status = 1
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
async function onBatchRemind() {
|
||||||
|
if (!selection.value.length) return
|
||||||
|
await ElMessageBox.confirm(`确认提醒选中的 ${selection.value.length} 位评分员?`, '批量提醒', { type: 'info' }).catch(() => {})
|
||||||
|
ElMessage.success(`已发起批量提醒 (前端示例, 共 ${selection.value.length} 条)`)
|
||||||
|
}
|
||||||
|
function onExport() { ElMessage.success(`已发起导出 (前端示例, 共 ${filtered.value.length} 条)`) }
|
||||||
|
|
||||||
|
// 状态 st-pill
|
||||||
|
function statusPill(r: ScoreRow) {
|
||||||
|
return r.status === 1
|
||||||
|
? { label: '已评分', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' }
|
||||||
|
: { label: '待评分', color: 'var(--st-warn-fg)', bg: 'var(--st-warn-bg)' }
|
||||||
|
}
|
||||||
|
// 分值颜色 (按分数高/中/低)
|
||||||
|
function scoreColor(s: number) {
|
||||||
|
if (s >= 90) return 'var(--green)'
|
||||||
|
if (s >= 75) return 'var(--brand)'
|
||||||
|
return 'var(--amber)'
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="rr-page">
|
<div class="rr-page">
|
||||||
<PageHeader title="供应商评分管理" sub="供应商评分记录和评分员管理" :data-source="dataSource" :api-error="apiError">
|
<PageHeader title="供应商评分管理" sub="供应商评分记录、评分员管理" :data-source="dataSource" :api-error="apiError">
|
||||||
<el-button type="primary" @click="onExport">导出明细</el-button>
|
<el-button @click="onExport">导出明细</el-button>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<el-tabs v-model="statusTab" @tab-change="onStatusTab" class="rr-tabs">
|
|
||||||
<el-tab-pane v-for="t in STATUS_DEFAULTS" :key="t.value" :name="t.value" :label="t.label" />
|
<!-- 顶部统计卡 -->
|
||||||
</el-tabs>
|
<div class="stats-grid">
|
||||||
<SearchBar v-model:filters="filters" :fields="searchFields" @search="onSearch" />
|
<div v-for="(s, i) in stats" :key="i" class="stat-card">
|
||||||
<DataTable :columns="columns" :data="filtered"
|
<div class="stat-icon" :style="{ color: s.color, background: 'var(--brand-soft)' }">
|
||||||
|
<el-icon><component :is="s.icon" /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<div class="stat-label">{{ s.label }}</div>
|
||||||
|
<div class="stat-value">{{ s.value }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SearchBar v-model:filters="filters" :fields="searchFields" @search="onSearch" @reset="onReset" />
|
||||||
|
|
||||||
|
<div class="rr-list-card">
|
||||||
|
<!-- 批量操作栏 -->
|
||||||
|
<div class="batch-bar" :class="{ 'has-selection': selection.length > 0 }">
|
||||||
|
<span v-if="selection.length > 0" class="batch-count">已选 <strong>{{ selection.length }}</strong> 条评分</span>
|
||||||
|
<span v-else class="batch-hint">勾选后可批量提醒评分员</span>
|
||||||
|
<div class="batch-actions">
|
||||||
|
<el-button size="small" plain :disabled="!selection.length" @click="onBatchRemind">批量提醒</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable :columns="columns" :data="filtered"
|
||||||
:borderless="true" :loading="loading" :page="page" :limit="limit" :total="total" row-key="id"
|
:borderless="true" :loading="loading" :page="page" :limit="limit" :total="total" row-key="id"
|
||||||
@page-change="(p: number) => { page = p; load() }" @limit-change="(l: number) => { limit = l; page = 1; load() }">
|
@selection-change="onSelectionChange"
|
||||||
<template #score="{ row }">
|
@page-change="(p: number) => { page = p; load() }"
|
||||||
<span style="font-weight: 600; color: var(--primary);">{{ row.score }}</span>
|
@limit-change="(l: number) => { limit = l; page = 1; load() }">
|
||||||
</template>
|
<template #score="{ row }">
|
||||||
<template #status="{ row }">
|
<span :style="{ fontWeight: 700, color: scoreColor(row.score) }">{{ row.score }}</span>
|
||||||
<el-tag :type="row.status === 1 ? 'success' : 'warning'" size="small">{{ row.status === 1 ? '已评分' : '未评分' }}</el-tag>
|
</template>
|
||||||
</template>
|
<template #status="{ row }">
|
||||||
<template #deadline="{ row }">{{ row.deadline }}</template>
|
<span class="st-pill" :style="{ color: statusPill(row).color, background: statusPill(row).bg }">
|
||||||
<template #actions="{ row }">
|
<span class="st-dot" />{{ statusPill(row).label }}
|
||||||
<el-button type="primary" link @click="onView(row)">详情</el-button>
|
</span>
|
||||||
<el-button v-if="row.status === 0" type="success" link @click="onScore(row)">去打分</el-button>
|
</template>
|
||||||
</template>
|
<template #actions="{ row }">
|
||||||
</DataTable>
|
<TableActions
|
||||||
|
:row="row"
|
||||||
|
:actions="[
|
||||||
|
{ label: '详情', type: 'primary', onClick: onView },
|
||||||
|
{ label: '去打分', type: 'success', when: (r: any) => r.status === 0, onClick: onScore }
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
|
||||||
|
<style scoped lang="scss">
|
||||||
.rr-page { padding: 16px; }
|
.rr-page { padding: 16px; }
|
||||||
.rr-tabs { margin-bottom: 12px; }
|
|
||||||
|
/* 顶部统计卡 */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg); padding: 16px 18px;
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
transition: all .15s;
|
||||||
|
&:hover { border-color: var(--border-strong); box-shadow: var(--shadow); }
|
||||||
|
}
|
||||||
|
.stat-icon {
|
||||||
|
width: 40px; height: 40px; border-radius: 10px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.stat-info { flex: 1; min-width: 0; }
|
||||||
|
.stat-label { font-size: 12px; color: var(--ink-3); margin-bottom: 4px; }
|
||||||
|
.stat-value { font-size: 22px; font-weight: 700; color: var(--ink); letter-spacing: -0.02em; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -201,7 +201,7 @@ onMounted(() => load())
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 进件详情弹窗 (EnterpriseMerchantDTO 核心字段) -->
|
<!-- 进件详情弹窗 (EnterpriseMerchantDTO 核心字段) -->
|
||||||
<el-dialog v-model="detailVisible" title="商户进件详情" width="760px" :close-on-click-modal="false" destroy-on-close>
|
<el-dialog v-model="detailVisible" title="商户进件详情" width="800px" :close-on-click-modal="false" destroy-on-close>
|
||||||
<div v-loading="detailLoading">
|
<div v-loading="detailLoading">
|
||||||
<template v-if="merchant">
|
<template v-if="merchant">
|
||||||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:12px">
|
<div style="display:flex;gap:8px;align-items:center;margin-bottom:12px">
|
||||||
|
|||||||
@ -216,7 +216,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{ path: '/sys/operation/association', component: () => import('@/modules/sys/operation/OperationListView.vue'), props: () => ({ type: 4 }), meta: { title: '行业协会运营' } },
|
{ path: '/sys/operation/association', component: () => import('@/modules/sys/operation/OperationListView.vue'), props: () => ({ type: 4 }), meta: { title: '行业协会运营' } },
|
||||||
// 3 个资讯类 (P1: information -> 真 view, industry/categoryZX 404 保留 mock)
|
// 3 个资讯类 (P1: information -> 真 view, industry/categoryZX 404 保留 mock)
|
||||||
{ path: '/other/information', component: () => import('@/modules/other/information/InformationListView.vue'), meta: { title: '资讯列表' } },
|
{ path: '/other/information', component: () => import('@/modules/other/information/InformationListView.vue'), meta: { title: '资讯列表' } },
|
||||||
{ path: '/other/industry', component: () => import('@/modules/other/category/CategoryDeepListView.vue'), props: { type: 1, title: '行业管理' }, meta: { title: '行业' } },
|
{ path: '/other/industry', component: () => import('@/modules/other/category/CategoryDeepListView.vue'), props: { type: 5, title: '行业管理' }, meta: { title: '行业' } },
|
||||||
{ path: '/other/category/zx', component: () => import('@/modules/other/category/CategoryDeepListView.vue'), props: { type: 3, title: '资讯分类' }, meta: { title: '资讯分类' } },
|
{ path: '/other/category/zx', component: () => import('@/modules/other/category/CategoryDeepListView.vue'), props: { type: 3, title: '资讯分类' }, meta: { title: '资讯分类' } },
|
||||||
// devtools 5 个
|
// devtools 5 个
|
||||||
{ path: '/devtools/baseclass', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'devtools-baseclass' }), meta: { title: '基类配置' } },
|
{ path: '/devtools/baseclass', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'devtools-baseclass' }), meta: { title: '基类配置' } },
|
||||||
@ -239,6 +239,14 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{ path: '/order/base-price-analysis', component: () => import('@/modules/order/OrderListView.vue'), meta: { title: '基础价格分析表' } },
|
{ path: '/order/base-price-analysis', component: () => import('@/modules/order/OrderListView.vue'), meta: { title: '基础价格分析表' } },
|
||||||
// W15 新增 - 订单售后 + 退款 + 付款回调 (前端 max-effort mock, 老 vue 同字段)
|
// W15 新增 - 订单售后 + 退款 + 付款回调 (前端 max-effort mock, 老 vue 同字段)
|
||||||
{ path: '/order/orderpay', component: () => import('@/modules/order/payment/PaymentCallbackListView.vue'), meta: { title: '订单支付回调' } },
|
{ path: '/order/orderpay', component: () => import('@/modules/order/payment/PaymentCallbackListView.vue'), meta: { title: '订单支付回调' } },
|
||||||
|
// 平台服务费(居间费)管理 2026-09-29
|
||||||
|
{ path: '/order/servicefee', component: () => import('@/modules/order/ServiceFeeListView.vue'), meta: { title: '平台服务费管理' } },
|
||||||
|
// 补录管理 2026-09-29
|
||||||
|
{ path: '/order/backfill-manage', component: () => import('@/modules/order/BackfillManageListView.vue'), meta: { title: '补录管理' } },
|
||||||
|
// 企业评分与等级管理 2026-09-29
|
||||||
|
{ path: '/user/enterprise-score', component: () => import('@/modules/user/EnterpriseScoreListView.vue'), meta: { title: '企业评分与等级' } },
|
||||||
|
// AI 能力管理 2026-10-08
|
||||||
|
{ path: '/sys/ai-manage', component: () => import('@/modules/sys/AiManageListView.vue'), meta: { title: 'AI 能力管理' } },
|
||||||
{ path: '/order/refund', component: () => import('@/modules/order/refund/RefundProcessListView.vue'), meta: { title: '订单退款管理' } },
|
{ path: '/order/refund', component: () => import('@/modules/order/refund/RefundProcessListView.vue'), meta: { title: '订单退款管理' } },
|
||||||
{ path: '/order/aftersale', component: () => import('@/modules/order/aftersale/AftersaleListView.vue'), meta: { title: '售后订单' } },
|
{ path: '/order/aftersale', component: () => import('@/modules/order/aftersale/AftersaleListView.vue'), meta: { title: '售后订单' } },
|
||||||
// other 7 个
|
// other 7 个
|
||||||
@ -276,7 +284,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{ path: '/sys/menu', component: () => import('@/modules/sys/MenuListView.vue'), meta: { title: '菜单管理' } },
|
{ path: '/sys/menu', component: () => import('@/modules/sys/MenuListView.vue'), meta: { title: '菜单管理' } },
|
||||||
// user 9 个
|
// user 9 个
|
||||||
{ path: '/user/enterprise/level', component: () => import('@/modules/user/enterpriselevel/EnterpriselevelListView.vue'), meta: { title: '企业等级管理' } },
|
{ path: '/user/enterprise/level', component: () => import('@/modules/user/enterpriselevel/EnterpriselevelListView.vue'), meta: { title: '企业等级管理' } },
|
||||||
{ path: '/user/enterprise/rating', component: () => import('@/modules/user/enterpriserating/EnterpriseratingListView.vue'), meta: { title: '供应商评级' } },
|
{ path: '/user/enterprise/rating', redirect: '/user/enterprise-score' }, // 旧mock评级页下线 2026-09-29, 统一到评分等级体系
|
||||||
{ path: '/user/enterprise/score', component: () => import('@/modules/user/enterprisescore/EnterprisescoreListView.vue'), meta: { title: '供应商评分' } },
|
{ path: '/user/enterprise/score', component: () => import('@/modules/user/enterprisescore/EnterprisescoreListView.vue'), meta: { title: '供应商评分' } },
|
||||||
{ path: '/user/finance/recharge', component: () => import('@/modules/user/finance/AccountTransactionListView.vue'), props: () => ({ type: 1 }), meta: { title: '充值管理' } },
|
{ path: '/user/finance/recharge', component: () => import('@/modules/user/finance/AccountTransactionListView.vue'), props: () => ({ type: 1 }), meta: { title: '充值管理' } },
|
||||||
{ path: '/user/finance/recharge-tx', component: () => import('@/modules/user/finance/AccountTransactionListView.vue'), props: () => ({ type: 2 }), meta: { title: '提现管理' } },
|
{ path: '/user/finance/recharge-tx', component: () => import('@/modules/user/finance/AccountTransactionListView.vue'), props: () => ({ type: 2 }), meta: { title: '提现管理' } },
|
||||||
|
|||||||
145
src/styles/global-ui.scss
Normal file
145
src/styles/global-ui.scss
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
/* ============================================================
|
||||||
|
* 后台全局 UI 统一规范 (2026-08-18 建立, 2026-08-21 补约定注释)
|
||||||
|
* 由 UI 审查驱动, 解决跨页视觉一致性缺口:
|
||||||
|
* 1. 页面骨架统一 (rr-page 默认内边距)
|
||||||
|
* 2. 表格视觉统一 (表头底色/圆角/消除老页面 border 竖线)
|
||||||
|
* 3. 卡片圆角统一
|
||||||
|
* 4. 状态标签/el-tag 暗色主题适配
|
||||||
|
* 设计原则: 只做"兜底默认", 页面 scoped 样式优先覆盖
|
||||||
|
*
|
||||||
|
* ────────── 组件使用约定 (审计后定) ──────────
|
||||||
|
* ◆ 状态呈现:
|
||||||
|
* - 列表页表格的状态/标志列: 统一用 `.st-pill` (圆点+胶囊, --st-* token)
|
||||||
|
* 例: <span class="st-pill" :style="{color,background}"><span class="st-dot"/>正常</span>
|
||||||
|
* - 详情页/向导/标签: 用原生 <el-tag type="success|warning|danger|info">
|
||||||
|
* (global-ui.scss 已写 el-tag 四态暗色适配)
|
||||||
|
* - 不要混用: 同一项目里两种风格并存会出现"有没有圆点"的视觉差
|
||||||
|
*
|
||||||
|
* ◆ 表格列对齐:
|
||||||
|
* - 识别性内容 (名称/手机/编号/地址) 左对齐, header 也左对齐
|
||||||
|
* - 纯数据 (时间/金额/数量/状态) 居中, header 也居中
|
||||||
|
* - 操作列固定居中且 fixed="right"
|
||||||
|
*
|
||||||
|
* ◆ Dialog 宽度: 仅允许 4 档 — 500px(small) / 640px(medium) / 800px(large) / 960px(xlarge)
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
/* ---------- 1. 页面骨架: 默认 16px 内边距 (页面未定义时生效) ---------- */
|
||||||
|
.rr-page { padding: 16px; }
|
||||||
|
|
||||||
|
/* ---------- 2. 表格视觉统一 ---------- */
|
||||||
|
/* 表头底色/文字统一 (el-table 全局) */
|
||||||
|
.el-table {
|
||||||
|
--el-table-header-bg-color: var(--surface-2, #f1f5f9);
|
||||||
|
--el-table-header-text-color: var(--ink-3, #64748b);
|
||||||
|
}
|
||||||
|
.el-table th.el-table__cell {
|
||||||
|
background: var(--surface-2, #f1f5f9) !important; /* !important 覆盖 EP .el-table--border 表头白底 */
|
||||||
|
color: var(--ink-3, #64748b);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
/* 单元格内边距统一 (与 DataTable 规范一致) */
|
||||||
|
.el-table td.el-table__cell { padding: 10px 12px; }
|
||||||
|
|
||||||
|
/* 消除老页面裸 el-table border 属性产生的边框/竖线 → 统一 borderless 视觉 */
|
||||||
|
.el-table--border,
|
||||||
|
.el-table--border .el-table__cell,
|
||||||
|
.el-table--border th.el-table__cell,
|
||||||
|
.el-table--border td.el-table__cell {
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
.el-table--border .el-table__inner-wrapper::after,
|
||||||
|
.el-table--border .el-table__inner-wrapper::before,
|
||||||
|
.el-table::before,
|
||||||
|
.el-table::after {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 3. 卡片圆角统一 ---------- */
|
||||||
|
.rr-list-card,
|
||||||
|
.rr-card,
|
||||||
|
.info-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 5. el-tag 统一胶囊外观 (所有页面状态/徽标一致) ---------- */
|
||||||
|
/* EP 变量级联下 --el-tag-border-radius 仍被覆盖为 4px, 直接属性 !important 兜底 */
|
||||||
|
.el-tag {
|
||||||
|
border-radius: 999px !important;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 6. 通用状态胶囊 (圆点+胶囊, 颜色经 inline style 传 var() 或 --st-* 变量) ---------- */
|
||||||
|
.st-pill {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
font-size: 12px; font-weight: 500; padding: 3px 12px; border-radius: 999px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.st-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||||
|
|
||||||
|
/* ---------- 7. 批量操作栏通用样式 (所有列表页复用) ---------- */
|
||||||
|
.batch-bar {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 12px 16px; border-bottom: 1px solid var(--border);
|
||||||
|
transition: background .2s;
|
||||||
|
}
|
||||||
|
.batch-bar.has-selection { background: var(--primary-soft, #e0ecff); }
|
||||||
|
.batch-count { font-size: 13px; color: var(--ink-2); }
|
||||||
|
.batch-count strong { color: var(--primary); font-size: 15px; }
|
||||||
|
.batch-hint { font-size: 12px; color: var(--ink-3); }
|
||||||
|
.batch-actions { margin-left: auto; display: flex; gap: 4px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* ---------- 8. 图片预览浮层: 最高层级兜底 (不被表格/弹窗盖住) ---------- */
|
||||||
|
.el-image-viewer__wrapper {
|
||||||
|
z-index: 3100 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 4. 状态色暗色适配 ---------- */
|
||||||
|
/* 状态语义色 token (亮色默认) — 各页面状态标签通过 var() 引用, 主题切换自动适配 */
|
||||||
|
:root,
|
||||||
|
[data-theme="tech-blue"],
|
||||||
|
[data-theme="red"],
|
||||||
|
[data-theme="purple"] {
|
||||||
|
--st-warn-fg: #b45309; --st-warn-bg: #fef3c7;
|
||||||
|
--st-ok-fg: #15803d; --st-ok-bg: #dcfce7;
|
||||||
|
--st-err-fg: #b91c1c; --st-err-bg: #fee2e2;
|
||||||
|
--st-lv1-fg: #92400e; --st-lv1-bg: #fef3c7;
|
||||||
|
--st-lv2-fg: #6d28d9; --st-lv2-bg: #ede9fe;
|
||||||
|
--st-lv3-fg: #c2410c; --st-lv3-bg: #ffedd5;
|
||||||
|
--st-lv4-fg: #475569; --st-lv4-bg: #f1f5f9;
|
||||||
|
--st-lv5-fg: #a16207; --st-lv5-bg: #fefce8;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--st-warn-fg: #fcd34d; --st-warn-bg: rgba(245, 158, 11, 0.16);
|
||||||
|
--st-ok-fg: #6ee7b7; --st-ok-bg: rgba(16, 185, 129, 0.16);
|
||||||
|
--st-err-fg: #fca5a5; --st-err-bg: rgba(239, 68, 68, 0.16);
|
||||||
|
--st-lv1-fg: #fcd34d; --st-lv1-bg: rgba(245, 158, 11, 0.14);
|
||||||
|
--st-lv2-fg: #c4b5fd; --st-lv2-bg: rgba(109, 40, 217, 0.2);
|
||||||
|
--st-lv3-fg: #fdba74; --st-lv3-bg: rgba(194, 65, 12, 0.2);
|
||||||
|
--st-lv4-fg: #cbd5e1; --st-lv4-bg: rgba(100, 116, 139, 0.2);
|
||||||
|
--st-lv5-fg: #fde047; --st-lv5-bg: rgba(161, 98, 7, 0.2);
|
||||||
|
|
||||||
|
/* el-tag 四态: 暗色下深底浅字 */
|
||||||
|
.el-tag--success {
|
||||||
|
--el-tag-bg-color: rgba(16, 185, 129, 0.16);
|
||||||
|
--el-tag-text-color: #6ee7b7;
|
||||||
|
--el-tag-border-color: rgba(16, 185, 129, 0.4);
|
||||||
|
}
|
||||||
|
.el-tag--warning {
|
||||||
|
--el-tag-bg-color: rgba(245, 158, 11, 0.16);
|
||||||
|
--el-tag-text-color: #fcd34d;
|
||||||
|
--el-tag-border-color: rgba(245, 158, 11, 0.4);
|
||||||
|
}
|
||||||
|
.el-tag--danger {
|
||||||
|
--el-tag-bg-color: rgba(239, 68, 68, 0.16);
|
||||||
|
--el-tag-text-color: #fca5a5;
|
||||||
|
--el-tag-border-color: rgba(239, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
.el-tag--info {
|
||||||
|
--el-tag-bg-color: rgba(148, 163, 184, 0.16);
|
||||||
|
--el-tag-text-color: #cbd5e1;
|
||||||
|
--el-tag-border-color: rgba(148, 163, 184, 0.4);
|
||||||
|
}
|
||||||
|
/* 老式 rr-tag-* 状态类 (浅底深字 -> 深底浅字) */
|
||||||
|
.rr-tag-warn { background: rgba(245, 158, 11, 0.16); color: #fcd34d; }
|
||||||
|
.rr-tag-ok { background: rgba(16, 185, 129, 0.16); color: #6ee7b7; }
|
||||||
|
.rr-tag-err { background: rgba(239, 68, 68, 0.16); color: #fca5a5; }
|
||||||
|
}
|
||||||
@ -24,9 +24,11 @@
|
|||||||
--brand-2: #1D4ED8;
|
--brand-2: #1D4ED8;
|
||||||
--brand-soft: #DBEAFE;
|
--brand-soft: #DBEAFE;
|
||||||
--brand-hover: #1E40AF;
|
--brand-hover: #1E40AF;
|
||||||
|
--primary: var(--brand);
|
||||||
|
--primary-soft: var(--brand-soft);
|
||||||
--brand-glow: rgba(37, 99, 235, 0.15);
|
--brand-glow: rgba(37, 99, 235, 0.15);
|
||||||
// 登录页渐变
|
// 登录页渐变
|
||||||
--login-grad-1: #EFF6FF;
|
--login-grad-1: #C7DBF7;
|
||||||
--login-grad-2: #DBEAFE;
|
--login-grad-2: #DBEAFE;
|
||||||
--login-grad-3: #E0E7FF;
|
--login-grad-3: #E0E7FF;
|
||||||
--login-glow-1: rgba(37, 99, 235, 0.18);
|
--login-glow-1: rgba(37, 99, 235, 0.18);
|
||||||
@ -72,6 +74,8 @@
|
|||||||
--brand-2: #B91C1C;
|
--brand-2: #B91C1C;
|
||||||
--brand-soft: #FEE2E2;
|
--brand-soft: #FEE2E2;
|
||||||
--brand-hover: #991B1B;
|
--brand-hover: #991B1B;
|
||||||
|
--primary: var(--brand);
|
||||||
|
--primary-soft: var(--brand-soft);
|
||||||
--brand-glow: rgba(220, 38, 38, 0.15);
|
--brand-glow: rgba(220, 38, 38, 0.15);
|
||||||
--login-grad-1: #FEF2F2;
|
--login-grad-1: #FEF2F2;
|
||||||
--login-grad-2: #FECACA;
|
--login-grad-2: #FECACA;
|
||||||
@ -117,6 +121,8 @@
|
|||||||
--brand-2: #4736BC;
|
--brand-2: #4736BC;
|
||||||
--brand-soft: #EDEBFC;
|
--brand-soft: #EDEBFC;
|
||||||
--brand-hover: #392BA0;
|
--brand-hover: #392BA0;
|
||||||
|
--primary: var(--brand);
|
||||||
|
--primary-soft: var(--brand-soft);
|
||||||
--brand-glow: rgba(86, 69, 212, 0.15);
|
--brand-glow: rgba(86, 69, 212, 0.15);
|
||||||
--login-grad-1: #F3F1FE;
|
--login-grad-1: #F3F1FE;
|
||||||
--login-grad-2: #E8E5FB;
|
--login-grad-2: #E8E5FB;
|
||||||
@ -164,6 +170,8 @@
|
|||||||
--brand-2: #3B82F6;
|
--brand-2: #3B82F6;
|
||||||
--brand-soft: rgba(96, 165, 250, 0.15);
|
--brand-soft: rgba(96, 165, 250, 0.15);
|
||||||
--brand-hover: #93C5FD;
|
--brand-hover: #93C5FD;
|
||||||
|
--primary: var(--brand);
|
||||||
|
--primary-soft: var(--brand-soft);
|
||||||
--brand-glow: rgba(96, 165, 250, 0.25);
|
--brand-glow: rgba(96, 165, 250, 0.25);
|
||||||
--login-grad-1: #1E293B;
|
--login-grad-1: #1E293B;
|
||||||
--login-grad-2: #0F172A;
|
--login-grad-2: #0F172A;
|
||||||
|
|||||||
11
src/types/wangeditor.d.ts
vendored
Normal file
11
src/types/wangeditor.d.ts
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
declare module "@wangeditor/editor-for-vue" {
|
||||||
|
import type { DefineComponent } from "vue"
|
||||||
|
export const Editor: DefineComponent<any, any, any>
|
||||||
|
export const Toolbar: DefineComponent<any, any, any>
|
||||||
|
}
|
||||||
|
declare module "@wangeditor/editor" {
|
||||||
|
export function createEditor(...args: any[]): any
|
||||||
|
export function createToolbar(...args: any[]): any
|
||||||
|
export const DomEditor: any
|
||||||
|
export type IDomEditor = any
|
||||||
|
}
|
||||||
@ -44,7 +44,10 @@ http.interceptors.response.use(
|
|||||||
// 业务级 code:401
|
// 业务级 code:401
|
||||||
// 未登录(无 token)访问受保护接口(如 /sys/menu/nav)→ 跳登录页
|
// 未登录(无 token)访问受保护接口(如 /sys/menu/nav)→ 跳登录页
|
||||||
// 已登录但权限不足(如 /openapi/** 带 token 被 Spring Security 拒)→ 保持 reject 给调用方
|
// 已登录但权限不足(如 /openapi/** 带 token 被 Spring Security 拒)→ 保持 reject 给调用方
|
||||||
if (body.code === 401 && !localStorage.getItem(STORAGE_KEY)) {
|
if (body.code === 401) {
|
||||||
|
// token 未登录/已失效(如 /sys/menu/nav 返回 {"code":401,"msg":"未授权"})
|
||||||
|
// 无论本地是否存有 token 都引导重新登录——token 过期(24h)时若只 reject 不跳转,
|
||||||
|
// 用户每天首次打开后台会看到菜单区"未授权"且无法恢复,只能手动退出重登
|
||||||
handleAuthExpired()
|
handleAuthExpired()
|
||||||
return Promise.reject(body)
|
return Promise.reject(body)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user