批量推送

This commit is contained in:
郭庆泰
2026-08-16 21:24:53 +08:00
parent 0c093076a4
commit ccfd03a6eb
55 changed files with 3772 additions and 577 deletions
+493
View File
@@ -9,6 +9,8 @@
"version": "1.0.0",
"dependencies": {
"@element-plus/icons-vue": "^2.3.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.6.0",
"element-plus": "^2.4.0",
"pinia": "^2.1.0",
@@ -63,6 +65,14 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.8",
"resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.8.tgz",
@@ -1169,12 +1179,22 @@
"win32"
]
},
"node_modules/@transloadit/prettier-bytes": {
"version": "0.0.7",
"resolved": "https://registry.npmmirror.com/@transloadit/prettier-bytes/-/prettier-bytes-0.0.7.tgz",
"integrity": "sha512-VeJbUb0wEKbcwaSlj5n+LscBl9IPgLPkHVGBkh00cztv6X4L/TJXK58LzFuBKX7/GAfiGhIwH67YTLTlzvIzBA=="
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true
},
"node_modules/@types/event-emitter": {
"version": "0.3.5",
"resolved": "https://registry.npmmirror.com/@types/event-emitter/-/event-emitter-0.3.5.tgz",
"integrity": "sha512-zx2/Gg0Eg7gwEiOIIh5w9TrhKKTeQh7CPCOPNc0el4pLSwzebA8SmnHwZs2dWlLONvyulykSwGSQxQHLhjGLvQ=="
},
"node_modules/@types/lodash": {
"version": "4.17.25",
"resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.25.tgz",
@@ -1193,6 +1213,56 @@
"resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="
},
"node_modules/@uppy/companion-client": {
"version": "2.2.2",
"resolved": "https://registry.npmmirror.com/@uppy/companion-client/-/companion-client-2.2.2.tgz",
"integrity": "sha512-5mTp2iq97/mYSisMaBtFRry6PTgZA6SIL7LePteOV5x0/DxKfrZW3DEiQERJmYpHzy7k8johpm2gHnEKto56Og==",
"dependencies": {
"@uppy/utils": "^4.1.2",
"namespace-emitter": "^2.0.1"
}
},
"node_modules/@uppy/core": {
"version": "2.3.4",
"resolved": "https://registry.npmmirror.com/@uppy/core/-/core-2.3.4.tgz",
"integrity": "sha512-iWAqppC8FD8mMVqewavCz+TNaet6HPXitmGXpGGREGrakZ4FeuWytVdrelydzTdXx6vVKkOmI2FLztGg73sENQ==",
"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.npmmirror.com/@uppy/store-default/-/store-default-2.1.1.tgz",
"integrity": "sha512-xnpTxvot2SeAwGwbvmJ899ASk5tYXhmZzD/aCFsXePh/v8rNvR2pKlcQUH7cF/y4baUGq3FHO/daKCok/mpKqQ=="
},
"node_modules/@uppy/utils": {
"version": "4.1.3",
"resolved": "https://registry.npmmirror.com/@uppy/utils/-/utils-4.1.3.tgz",
"integrity": "sha512-nTuMvwWYobnJcytDO3t+D6IkVq/Qs4Xv3vyoEZ+Iaf8gegZP+rEyoaFT2CK5XLRMienPyqRqNbIfRuFaOWSIFw==",
"dependencies": {
"lodash.throttle": "^4.1.1"
}
},
"node_modules/@uppy/xhr-upload": {
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/@uppy/xhr-upload/-/xhr-upload-2.1.3.tgz",
"integrity": "sha512-YWOQ6myBVPs+mhNjfdWsQyMRWUlrDLMoaG7nvf/G6Y3GKZf8AyjFDjvvJ49XWQ+DaZOftGkHmF1uh/DBeGivJQ==",
"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": {
"version": "5.2.4",
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
@@ -1335,6 +1405,156 @@
"vue": "^3.5.0"
}
},
"node_modules/@wangeditor/basic-modules": {
"version": "1.1.7",
"resolved": "https://registry.npmmirror.com/@wangeditor/basic-modules/-/basic-modules-1.1.7.tgz",
"integrity": "sha512-cY9CPkLJaqF05STqfpZKWG4LpxTMeGSIIF1fHvfm/mz+JXatCagjdkbxdikOuKYlxDdeqvOeBmsUBItufDLXZg==",
"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.npmmirror.com/@wangeditor/code-highlight/-/code-highlight-1.0.3.tgz",
"integrity": "sha512-iazHwO14XpCuIWJNTQTikqUhGKyqj+dUNWJ9288Oym9M2xMVHvnsOmDU2sgUDWVy+pOLojReMPgXCsvvNlOOhw==",
"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.npmmirror.com/@wangeditor/core/-/core-1.1.19.tgz",
"integrity": "sha512-KevkB47+7GhVszyYF2pKGKtCSj/YzmClsD03C3zTt+9SR2XWT5T0e3yQqg8baZpcMvkjs1D8Dv4fk8ok/UaS2Q==",
"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.npmmirror.com/@wangeditor/editor/-/editor-5.1.23.tgz",
"integrity": "sha512-0RxfeVTuK1tktUaPROnCoFfaHVJpRAIE2zdS0mpP+vq1axVQpLjM8+fCvKzqYIkH0Pg+C+44hJpe3VVroSkEuQ==",
"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.npmmirror.com/@wangeditor/editor-for-vue/-/editor-for-vue-5.1.12.tgz",
"integrity": "sha512-0Ds3D8I+xnpNWezAeO7HmPRgTfUxHLMd9JKcIw+QzvSmhC5xUHbpCcLU+KLmeBKTR/zffnS5GQo6qi3GhTMJWQ==",
"peerDependencies": {
"@wangeditor/editor": ">=5.1.0",
"vue": "^3.0.5"
}
},
"node_modules/@wangeditor/list-module": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/@wangeditor/list-module/-/list-module-1.0.5.tgz",
"integrity": "sha512-uDuYTP6DVhcYf7mF1pTlmNn5jOb4QtcVhYwSSAkyg09zqxI1qBqsfUnveeDeDqIuptSJhkh81cyxi+MF8sEPOQ==",
"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.npmmirror.com/@wangeditor/table-module/-/table-module-1.1.4.tgz",
"integrity": "sha512-5saanU9xuEocxaemGdNi9t8MCDSucnykEC6jtuiT72kt+/Hhh4nERYx1J20OPsTCCdVr7hIyQenFD1iSRkIQ6w==",
"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.npmmirror.com/@wangeditor/upload-image-module/-/upload-image-module-1.0.2.tgz",
"integrity": "sha512-z81lk/v71OwPDYeQDxj6cVr81aDP90aFuywb8nPD6eQeECtOymrqRODjpO6VGvCVxVck8nUxBHtbxKtjgcwyiA==",
"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.npmmirror.com/@wangeditor/video-module/-/video-module-1.1.4.tgz",
"integrity": "sha512-ZdodDPqKQrgx3IwWu4ZiQmXI8EXZ3hm2/fM6E3t5dB8tCaIGWQZhmqd6P5knfkRAd3z2+YRSRbxOGfoRSp/rLg==",
"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": {
"version": "8.18.0",
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.18.0.tgz",
@@ -1537,6 +1757,11 @@
"node": ">= 0.8"
}
},
"node_modules/compute-scroll-into-view": {
"version": "1.0.20",
"resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
"integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg=="
},
"node_modules/confbox": {
"version": "0.1.8",
"resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz",
@@ -1548,6 +1773,18 @@
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
},
"node_modules/d": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/d/-/d-1.0.2.tgz",
"integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==",
"dependencies": {
"es5-ext": "^0.10.64",
"type": "^2.7.2"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/dayjs": {
"version": "1.11.21",
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz",
@@ -1600,6 +1837,14 @@
"resolved": "https://registry.npmmirror.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="
},
"node_modules/dom7": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/dom7/-/dom7-3.0.0.tgz",
"integrity": "sha512-oNlcUdHsC4zb7Msx7JN3K0Nro1dzJ48knvBOnDPKJ2GV9wl1i5vydJZUSyOfrkKFDZEud/jBsTk92S/VGSAe/g==",
"dependencies": {
"ssr-window": "^3.0.0-alpha.1"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1695,6 +1940,43 @@
"node": ">= 0.4"
}
},
"node_modules/es5-ext": {
"version": "0.10.64",
"resolved": "https://registry.npmmirror.com/es5-ext/-/es5-ext-0.10.64.tgz",
"integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==",
"hasInstallScript": true,
"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.npmmirror.com/es6-iterator/-/es6-iterator-2.0.3.tgz",
"integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==",
"dependencies": {
"d": "1",
"es5-ext": "^0.10.35",
"es6-symbol": "^3.1.1"
}
},
"node_modules/es6-symbol": {
"version": "3.1.4",
"resolved": "https://registry.npmmirror.com/es6-symbol/-/es6-symbol-3.1.4.tgz",
"integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==",
"dependencies": {
"d": "^1.0.2",
"ext": "^1.7.0"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz",
@@ -1745,17 +2027,48 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/esniff": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/esniff/-/esniff-2.0.1.tgz",
"integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==",
"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": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
},
"node_modules/event-emitter": {
"version": "0.3.5",
"resolved": "https://registry.npmmirror.com/event-emitter/-/event-emitter-0.3.5.tgz",
"integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==",
"dependencies": {
"d": "1",
"es5-ext": "~0.10.14"
}
},
"node_modules/exsolve": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.1.1.tgz",
"integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==",
"dev": true
},
"node_modules/ext": {
"version": "1.7.0",
"resolved": "https://registry.npmmirror.com/ext/-/ext-1.7.0.tgz",
"integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==",
"dependencies": {
"type": "^2.7.2"
}
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -1963,6 +2276,15 @@
"node": ">= 0.4"
}
},
"node_modules/html-void-elements": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/html-void-elements/-/html-void-elements-2.0.1.tgz",
"integrity": "sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
@@ -1975,6 +2297,23 @@
"node": ">= 6"
}
},
"node_modules/i18next": {
"version": "20.6.1",
"resolved": "https://registry.npmmirror.com/i18next/-/i18next-20.6.1.tgz",
"integrity": "sha512-yCMYTMEJ9ihCwEQQ3phLo7I/Pwycf8uAx+sRHwwk5U9Aui/IZYgQRyMqXafQOw5QQ7DM1Z+WyEXWIqSuJHhG2A==",
"dependencies": {
"@babel/runtime": "^7.12.0"
}
},
"node_modules/immer": {
"version": "9.0.21",
"resolved": "https://registry.npmmirror.com/immer/-/immer-9.0.21.tgz",
"integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/immutable": {
"version": "5.1.9",
"resolved": "https://registry.npmmirror.com/immutable/-/immutable-5.1.9.tgz",
@@ -2037,6 +2376,11 @@
"node": ">=0.10.0"
}
},
"node_modules/is-hotkey": {
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/is-hotkey/-/is-hotkey-0.2.0.tgz",
"integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw=="
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz",
@@ -2046,6 +2390,19 @@
"node": ">=0.12.0"
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmmirror.com/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww=="
},
"node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz",
@@ -2099,6 +2456,42 @@
"lodash-es": "*"
}
},
"node_modules/lodash.camelcase": {
"version": "4.3.0",
"resolved": "https://registry.npmmirror.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="
},
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ=="
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmmirror.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="
},
"node_modules/lodash.foreach": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/lodash.foreach/-/lodash.foreach-4.5.0.tgz",
"integrity": "sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ=="
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead."
},
"node_modules/lodash.throttle": {
"version": "4.1.1",
"resolved": "https://registry.npmmirror.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
"integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="
},
"node_modules/lodash.toarray": {
"version": "4.4.0",
"resolved": "https://registry.npmmirror.com/lodash.toarray/-/lodash.toarray-4.4.0.tgz",
"integrity": "sha512-QyffEA3i5dma5q2490+SgCvDN0pXLmRGSyAANuVi0HQ01Pkfr9fuoKQW8wm1wGBnJITs/mS7wQvS6VshUEBFCw=="
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
@@ -2162,6 +2555,14 @@
"node": ">= 0.6"
}
},
"node_modules/mime-match": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/mime-match/-/mime-match-1.0.2.tgz",
"integrity": "sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==",
"dependencies": {
"wildcard": "^1.1.0"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
@@ -2205,6 +2606,11 @@
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/namespace-emitter": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/namespace-emitter/-/namespace-emitter-2.0.1.tgz",
"integrity": "sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g=="
},
"node_modules/nanoid": {
"version": "3.3.18",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.18.tgz",
@@ -2222,6 +2628,11 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/next-tick": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/next-tick/-/next-tick-1.1.0.tgz",
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz",
@@ -2380,6 +2791,31 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/preact": {
"version": "10.29.8",
"resolved": "https://registry.npmmirror.com/preact/-/preact-10.29.8.tgz",
"integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==",
"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.npmmirror.com/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"engines": {
"node": ">=6"
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -2585,6 +3021,14 @@
"@parcel/watcher": "^2.4.1"
}
},
"node_modules/scroll-into-view-if-needed": {
"version": "2.2.31",
"resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
"integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==",
"dependencies": {
"compute-scroll-into-view": "^1.0.20"
}
},
"node_modules/scule": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz",
@@ -2596,6 +3040,35 @@
"resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
},
"node_modules/slate": {
"version": "0.72.8",
"resolved": "https://registry.npmmirror.com/slate/-/slate-0.72.8.tgz",
"integrity": "sha512-/nJwTswQgnRurpK+bGJFH1oM7naD5qDmHd89JyiKNT2oOKD8marW0QSBtuFnwEbL5aGCS8AmrhXQgNOsn4osAw==",
"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.npmmirror.com/slate-history/-/slate-history-0.66.0.tgz",
"integrity": "sha512-6MWpxGQZiMvSINlCbMW43E2YBSVMCMCIwQfBzGssjWw4kb0qfvj0pIdblWNRQZD0hR6WHP+dHHgGSeVdMWzfng==",
"dependencies": {
"is-plain-object": "^5.0.0"
},
"peerDependencies": {
"slate": ">=0.65.3"
}
},
"node_modules/snabbdom": {
"version": "3.6.4",
"resolved": "https://registry.npmmirror.com/snabbdom/-/snabbdom-3.6.4.tgz",
"integrity": "sha512-VmxEfuw1/Y/eFj5VtMhYnukExpYiPkNzoo3+N3qwAOUDMl8wXgbli5ebR+j0knE3lZ/0eYskLxNcX64uy10N9w==",
"engines": {
"node": ">=12.17.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2604,6 +3077,11 @@
"node": ">=0.10.0"
}
},
"node_modules/ssr-window": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/ssr-window/-/ssr-window-3.0.0.tgz",
"integrity": "sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA=="
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
@@ -2652,6 +3130,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tiny-warning": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/tiny-warning/-/tiny-warning-1.0.3.tgz",
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -2664,6 +3147,11 @@
"node": ">=8.0"
}
},
"node_modules/type": {
"version": "2.7.3",
"resolved": "https://registry.npmmirror.com/type/-/type-2.7.3.tgz",
"integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="
},
"node_modules/ufo": {
"version": "1.6.4",
"resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.4.tgz",
@@ -3013,6 +3501,11 @@
"resolved": "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="
},
"node_modules/wildcard": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/wildcard/-/wildcard-1.1.2.tgz",
"integrity": "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng=="
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+2
View File
@@ -9,6 +9,8 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.6.0",
"element-plus": "^2.4.0",
"pinia": "^2.1.0",
+10
View File
@@ -18,4 +18,14 @@ export function getCaptcha() {
export function getRouters() {
return request({ url: '/getRouters', method: 'get' })
}
// 短信登录 - 发送验证码 (手机号必须已注册)
export function sendLoginSms(phone) {
return request({ url: '/business/auth/smsSendCode', method: 'post', data: { phone } })
}
// 短信登录 - 校验验证码并登录, 返回 res.data 含 token
export function smsLogin(data) {
return request({ url: '/business/auth/smsLogin', method: 'post', data })
}
+152
View File
@@ -0,0 +1,152 @@
<!--
通用 OSS 文件上传控件 (支持 PDF / 图片 / 自定义 accept)
OssImageUploader 不同: 这里不强制要求图片类型, 显示上传的文件名/图标
用法:
<oss-file-uploader
v-model="form.fileUrl"
:dir="'ry8080/special-plan/'"
accept=".pdf,.png,.jpg,.jpeg"
placeholder="点击上传文件"
/>
-->
<template>
<div class="ht-file-upload" :class="{ 'has-file': modelValue, 'is-block': block, readonly }" @click="handleClick">
<div v-if="!modelValue && !readonly" class="ht-file-placeholder">
<span class="placeholder-text">{{ placeholder }}</span>
<span class="placeholder-hint" v-if="hint">{{ hint }}</span>
</div>
<div v-else-if="modelValue" class="ht-file-info">
<el-icon class="file-icon" :size="22"><svg viewBox="0 0 24 24" fill="currentColor">
<path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-1 7V3.5L18.5 9H13z"/>
</svg></el-icon>
<div class="file-meta">
<a class="file-name" :href="modelValue" target="_blank" @click.stop>{{ fileName }}</a>
<span class="file-type">{{ ext.toUpperCase() }} · {{ fileSizeText }}</span>
</div>
<el-button v-if="!readonly" link type="danger" size="small" class="remove-btn" @click.stop="onRemove">移除</el-button>
</div>
</div>
<input
type="file"
ref="fileInput"
:accept="accept"
style="display:none"
@change="onFileChange"
/>
</template>
<script setup>
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { uploadToOss } from '@/utils/oss'
const props = defineProps({
modelValue: { type: String, default: '' },
dir: { type: String, default: 'ry8080/file/' },
placeholder: { type: String, default: '点击上传文件' },
hint: { type: String, default: '' },
accept: { type: String, default: '.pdf,.png,.jpg,.jpeg' },
maxSize: { type: Number, default: 10 }, // MB
block: { type: Boolean, default: false },
readonly: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue'])
const fileInput = ref(null)
const uploading = ref(false)
const fileName = computed(() => {
if (!props.modelValue) return ''
// 取 URL 最后一段, 去 query 参数
const url = props.modelValue.split('?')[0]
return url.substring(url.lastIndexOf('/') + 1)
})
const ext = computed(() => {
const n = fileName.value
const i = n.lastIndexOf('.')
return i >= 0 ? n.substring(i + 1) : ''
})
const fileSizeText = computed(() => '已上传')
function handleClick() {
if (props.readonly || uploading.value) return
fileInput.value?.click()
}
async function onFileChange(e) {
const file = e.target.files?.[0]
e.target.value = ''
if (!file) return
// 类型校验 (按 accept 列表)
const allowed = props.accept.split(',').map(s => s.trim().toLowerCase()).filter(Boolean)
if (allowed.length) {
const lower = file.name.toLowerCase()
const ok = allowed.some(rule => {
if (rule.startsWith('.')) return lower.endsWith(rule)
if (rule.endsWith('/*')) return file.type.startsWith(rule.slice(0, -1))
return file.type === rule
})
if (!ok) return ElMessage.warning('文件类型不符, 允许: ' + props.accept)
}
if (file.size / 1024 / 1024 > props.maxSize) {
return ElMessage.warning(`文件大小不能超过 ${props.maxSize}MB`)
}
uploading.value = true
try {
const url = await uploadToOss(file, props.dir)
emit('update:modelValue', url)
ElMessage.success('上传成功')
} catch (err) {
ElMessage.error(err.message || '上传失败')
} finally {
uploading.value = false
}
}
function onRemove() {
emit('update:modelValue', '')
}
</script>
<style scoped>
.ht-file-upload {
display: flex;
align-items: center;
gap: 10px;
border: 1px dashed #d9d9d9;
border-radius: 4px;
background: #fafafa;
cursor: pointer;
padding: 12px 14px;
transition: border-color 0.2s;
min-height: 60px;
}
.ht-file-upload:hover { border-color: #1890ff; }
.ht-file-upload.has-file { border-style: solid; border-color: #52c41a; background: #fff; }
.ht-file-upload.is-block { width: 100%; }
.ht-file-upload.readonly { cursor: default; }
.ht-file-placeholder {
flex: 1;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 4px;
}
.placeholder-text { font-size: 14px; color: #8c8c8c; }
.placeholder-hint { font-size: 12px; color: #c0c4cc; }
.ht-file-info {
display: flex; align-items: center; gap: 12px; flex: 1; min-width: 0;
}
.file-icon { color: #1890ff; flex-shrink: 0; }
.file-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.file-name {
font-size: 14px; color: #1a1a1a; font-weight: 500;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
cursor: pointer;
}
.file-name:hover { color: #1890ff; text-decoration: underline; }
.file-type { font-size: 12px; color: #909399; }
.remove-btn { flex-shrink: 0; }
</style>
+79
View File
@@ -0,0 +1,79 @@
<template>
<div class="rich-editor-wrap">
<Toolbar
:editor="editorRef"
:default-config="toolbarConfig"
:mode="mode"
class="rich-editor-toolbar"
/>
<Editor
v-model="editorValue"
:default-config="editorConfig"
:mode="mode"
style="height: 400px; overflow-y: hidden;"
@on-created="onCreated"
@on-change="handleChange"
/>
</div>
</template>
<script setup>
import { ref, shallowRef, computed, onBeforeUnmount } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import '@wangeditor/editor/dist/css/style.css'
const props = defineProps({
modelValue: { type: String, default: '' },
placeholder: { type: String, default: '请输入内容...' },
mode: { type: String, default: 'default' } // 'default' | 'simple'
})
const emit = defineEmits(['update:modelValue', 'change'])
// prop 是只读的, 用 computed 中转
const editorValue = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const editorRef = shallowRef(null)
const toolbarConfig = {
excludeKeys: [
'group-video', // 视频上传, 避免服务器无视频上传接口
'insertVideo',
'uploadVideo'
]
}
const editorConfig = {
placeholder: props.placeholder,
MENU_CONF: {
uploadImage: {
// 简化: 不接图片上传接口, 用户直接粘贴/拖拽本地图片由浏览器转 base64
customUpload: () => {}
}
}
}
function onCreated(editor) {
editorRef.value = editor
}
function handleChange(editor) {
emit('change', editor.getHtml())
}
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor) editor.destroy()
})
</script>
<style scoped>
.rich-editor-wrap {
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #fff;
}
.rich-editor-toolbar {
border-bottom: 1px solid #dcdfe6;
}
</style>
+62 -12
View File
@@ -6,10 +6,24 @@
<div class="logo-sub">BAHIM 工作台</div>
</div>
<el-menu :default-active="route.path" router class="aside-menu">
<el-menu-item v-for="m in menu" :key="m.path" :index="m.path">
<el-icon><component :is="m.icon" /></el-icon>
<span>{{ m.title }}</span>
</el-menu-item>
<template v-for="m in menu" :key="m.path">
<!-- 二级菜单: children 的用 el-sub-menu -->
<el-sub-menu v-if="m.children" :index="m.path">
<template #title>
<el-icon><component :is="m.icon" /></el-icon>
<span>{{ m.title }}</span>
</template>
<el-menu-item v-for="c in m.children" :key="c.path" :index="c.path">
<el-icon><component :is="c.icon" /></el-icon>
<span>{{ c.title }}</span>
</el-menu-item>
</el-sub-menu>
<!-- 一级菜单 -->
<el-menu-item v-else :index="m.path">
<el-icon><component :is="m.icon" /></el-icon>
<span>{{ m.title }}</span>
</el-menu-item>
</template>
</el-menu>
</el-aside>
<el-container>
@@ -41,7 +55,7 @@ import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { logout as logoutApi } from '@/api/auth'
import { House, Document, Calendar, User, List, OfficeBuilding, Setting, Bell, EditPen, DataAnalysis, Tickets, CaretBottom, Folder, Medal, UserFilled, Connection } from '@element-plus/icons-vue'
import { House, Document, Calendar, User, List, OfficeBuilding, Setting, Bell, EditPen, DataAnalysis, Tickets, CaretBottom, Folder, Medal, UserFilled, Connection, Box, Star, Grid, Files, Compass } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
@@ -58,11 +72,17 @@ const MENU = {
{ path: '/admin/roles', title: '角色管理', icon: Setting },
{ path: '/admin/projects', title: '项目全览', icon: Document },
{ path: '/admin/meetings', title: '会议全览', icon: Calendar },
{ path: '/admin/department', title: '科室管理', icon: Folder },
{ path: '/admin/title', title: '职称管理', icon: Medal },
{ path: '/admin/experts', title: '专家管理', icon: UserFilled },
{ path: '/admin/sponsor-orgs', title: '支持单位管理', icon: Connection },
{ path: '/admin/executor-orgs', title: '服务机构管理', icon: OfficeBuilding },
{ path: '/admin/library', title: '资料库管理', icon: Box, children: [
{ path: '/admin/experts', title: '专家管理', icon: UserFilled },
{ path: '/admin/sponsor-orgs', title: '支持单位管理', icon: Star },
{ path: '/admin/executor-orgs', title: '服务机构管理', icon: OfficeBuilding },
{ path: '/admin/department', title: '科室管理', icon: Grid },
{ path: '/admin/title', title: '职称管理', icon: Medal }
]},
{ path: '/admin/manage', title: '网站管理', icon: Setting, children: [
{ path: '/admin/article', title: '协议管理', icon: Files },
{ path: '/admin/special-plan', title: '七大专项计划', icon: Compass }
]},
{ path: '/admin/account', title: '账号信息', icon: User }
],
leader: [
@@ -124,8 +144,38 @@ const onCommand = (cmd) => { if (cmd === 'logout') logout() }
.logo-text { color: #fff; font-size: 16px; font-weight: 600; }
.logo-sub { color: #8a99b3; font-size: 12px; }
.aside-menu { background: #001529; border: none; }
:deep(.el-menu-item) { color: #c0c4cc; }
:deep(.el-menu-item.is-active) { background: #1890ff; color: #fff; }
/* 一级菜单 + 二级父标题: 统一默认/hover/active 样式 */
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
color: #c0c4cc;
}
:deep(.el-menu-item:hover),
:deep(.el-sub-menu__title:hover) {
background-color: #1f3a5f;
color: #fff;
}
/* 子项激活: 蓝底白字 */
:deep(.el-menu-item.is-active) {
background: #1890ff;
color: #fff;
}
/* 父标题在子项激活时高亮 (Element Plus 自动给 el-sub-menu 加 is-active) */
:deep(.el-sub-menu.is-active > .el-sub-menu__title) {
color: #fff;
}
/* 二级菜单容器: 跟随侧边栏深蓝底, 不要 Element Plus 默认白底 */
:deep(.el-menu--inline),
:deep(.el-menu--inline .el-menu-item) {
background-color: #001529;
}
:deep(.el-menu--inline .el-menu-item:hover) {
background-color: #1f3a5f;
}
:deep(.el-menu--inline .el-menu-item.is-active) {
background: #1890ff;
}
.topbar { background: #fff; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; border-bottom: 1px solid #ebeef5; }
.topbar-left { display: flex; align-items: center; }
.page-title { font-size: 16px; font-weight: 600; }
+7 -1
View File
@@ -13,7 +13,9 @@ const routes = [
{ path: 'register-expert', name: 'register-expert', component: () => import('@/views/auth/RegisterExpert.vue'), meta: { title: '专家注册' } },
{ path: 'register-executor', name: 'register-executor', component: () => import('@/views/auth/RegisterExecutor.vue'), meta: { title: '执行单位(供应商)注册' } },
{ path: 'register-sponsor', name: 'register-sponsor', component: () => import('@/views/auth/RegisterSponsor.vue'), meta: { title: '赞助方注册' } },
{ path: 'login', name: 'login', component: () => import('@/views/auth/Login.vue'), meta: { title: '登录' } }
{ path: 'login', name: 'login', component: () => import('@/views/auth/Login.vue'), meta: { title: '登录' } },
{ path: 'article/:type', name: 'portal-article', component: () => import('@/views/portal/ArticleView.vue'), meta: { title: '协议' } },
{ path: 'special-plan/:id', name: 'portal-special-plan', component: () => import('@/views/portal/SpecialPlanDetail.vue'), meta: { title: '专项计划详情' } }
]
},
// 各角色后台(登录后)
@@ -40,6 +42,10 @@ const routes = [
{ path: 'executor-people/new', name: 'admin-executor-people-new', component: () => import('@/views/admin/ExecutorPersonNew.vue'), meta: { title: '新建人员' } },
{ path: 'executor-people/edit/:id', name: 'admin-executor-people-edit', component: () => import('@/views/admin/ExecutorPersonNew.vue'), meta: { title: '编辑人员' } },
{ path: 'orgs', name: 'admin-orgs', component: () => import('@/views/admin/Orgs.vue'), meta: { title: '公司管理' } },
{ path: 'article', name: 'admin-article', component: () => import('@/views/admin/BizArticleAdmin.vue'), meta: { title: '协议管理' } },
{ path: 'article/edit/:id', name: 'admin-article-edit', component: () => import('@/views/admin/BizArticleEdit.vue'), meta: { title: '编辑文章' } },
{ path: 'special-plan', name: 'admin-special-plan', component: () => import('@/views/admin/BizSpecialPlanAdmin.vue'), meta: { title: '七大专项计划' } },
{ path: 'special-plan/edit/:id', name: 'admin-special-plan-edit', component: () => import('@/views/admin/BizSpecialPlanEdit.vue'), meta: { title: '编辑专项计划' } },
{ path: 'account', name: 'admin-account', component: () => import('@/views/admin/Account.vue'), meta: { title: '账号信息' } }
]
},
+41
View File
@@ -0,0 +1,41 @@
import { ref } from 'vue'
/**
* 异步操作防重入锁 (UI 层面的"防抖")
*
* 适用场景: 接口响应慢, 用户在等待期间可重复点击同一按钮, 导致并发请求.
* - 第一次调用 run(): locked 置 true, 执行 fn, 完成后 (无论成败) 解锁
* - 锁定期间的调用: 立即 return, 不发起任何请求
*
* 用法:
* const { locked, run } = useAsyncLock()
*
* async function sendSms() {
* if (!valid) return
* await run(async () => {
* const r = await request(...)
* // ... 业务处理 ...
* })
* }
*
* 模板绑定:
* <el-button :loading="locked" :disabled="locked || ..." @click="sendSms">发送验证码</el-button>
*
* 注意: 这是"按钮重入锁", 不是传统 lodash.debounce (后者会延迟执行). 这里
* 第一次点击立即发出, 但锁定期间所有点击被丢弃 — 这才是"防重复请求"的正确语义.
*/
export function useAsyncLock() {
const locked = ref(false)
async function run(fn) {
if (locked.value) return undefined
locked.value = true
try {
return await fn()
} finally {
locked.value = false
}
}
return { locked, run }
}
-9
View File
@@ -2,15 +2,6 @@
<div class="page-card admin-account">
<div class="breadcrumb">首页 / 账号信息</div>
<el-descriptions :column="2" border style="margin-bottom: 24px">
<el-descriptions-item label="账号">{{ profile.userName }}</el-descriptions-item>
<el-descriptions-item label="姓名">{{ profile.nickName || profile.userName }}</el-descriptions-item>
<el-descriptions-item label="角色">{{ roleLabel }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ profile.phonenumber || '-' }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ profile.email || '-' }}</el-descriptions-item>
<el-descriptions-item label="最近登录">{{ profile.loginDate || '-' }}</el-descriptions-item>
</el-descriptions>
<el-tabs v-model="tab">
<el-tab-pane label="修改资料" name="profile">
<el-form :model="form" label-width="100px" class="account-form">
+102
View File
@@ -0,0 +1,102 @@
<template>
<div class="page-card admin-article">
<div class="breadcrumb">首页 / 协议管理</div>
<!-- 筛选 -->
<el-form inline :model="q" class="filter-form">
<el-form-item label="类型">
<el-select v-model="q.type" placeholder="全部" clearable style="width:160px">
<el-option label="用户协议" value="agreement" />
<el-option label="隐私政策" value="privacy" />
</el-select>
</el-form-item>
<el-form-item label="标题">
<el-input v-model="q.title" placeholder="输入标题" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="q.status" placeholder="全部" clearable style="width:120px">
<el-option label="启用" value="0" />
<el-option label="停用" value="1" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">查询</el-button>
<el-button @click="reset">重置</el-button>
</el-form-item>
</el-form>
<!-- 列表 -->
<el-table :data="rows" border stripe v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="title" label="标题" min-width="200" />
<el-table-column label="类型" width="120">
<template #default="{ row }">{{ TYPE_LABEL[row.type] || row.type }}</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === '0' ? 'success' : 'danger'">{{ row.status === '0' ? '启用' : '停用' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="updateTime" label="更新时间" width="170" />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="$router.push('/admin/article/edit/' + row.id)">编辑</el-button>
<el-button size="small" link type="primary" :disabled="row.status !== '0'" @click="preview(row)" target="_blank">预览</el-button>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
v-model:current-page="q.pageNum"
v-model:page-size="q.pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="load"
@size-change="load"
/>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import request from '@/utils/request'
const TYPE_LABEL = { agreement: '用户协议', privacy: '隐私政策' }
const q = reactive({ type: '', title: '', status: '', pageNum: 1, pageSize: 20 })
const rows = ref([])
const total = ref(0)
const loading = ref(false)
async function load() {
loading.value = true
try {
const r = await request({ url: '/business/article/list', method: 'get', params: { ...q } })
rows.value = (r.data && r.data.rows) || r.rows || []
total.value = (r.data && r.data.total) || r.total || 0
} finally { loading.value = false }
}
function reset() {
q.type = ''; q.title = ''; q.status = ''
q.pageNum = 1
load()
}
function preview(row) {
// 启用的文章才允许预览(实际注册页只看启用的), 这里做软拦截
window.open(`/#/article/${row.type}`, '_blank')
}
onMounted(load)
</script>
<style scoped>
.admin-article { padding: 16px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { margin-bottom: 12px; }
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
</style>
+107
View File
@@ -0,0 +1,107 @@
<template>
<div class="page-card biz-article-edit">
<div class="header">
<el-button :icon="ArrowLeft" @click="$router.back()">返回</el-button>
<span class="title">{{ isEdit ? '编辑文章' : '新建文章' }}</span>
</div>
<el-form :model="form" label-width="100px" v-loading="loading">
<el-form-item label="标题" required>
<el-input v-model="form.title" placeholder="请输入标题" maxlength="200" />
</el-form-item>
<el-form-item label="类型" required>
<el-select v-model="form.type" style="width:200px" :disabled="isEdit">
<el-option label="用户协议" value="agreement" />
<el-option label="隐私政策" value="privacy" />
</el-select>
<span class="form-tip" v-if="isEdit">类型不可修改</span>
</el-form-item>
<el-form-item label="状态" required>
<el-radio-group v-model="form.status">
<el-radio value="0">启用</el-radio>
<el-radio value="1">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="正文" required>
<RichEditor v-model="form.content" placeholder="请输入正文, 支持富文本格式" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="可选, 内部备注" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
<el-button @click="$router.back()">取消</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import request from '@/utils/request'
import RichEditor from '@/components/RichEditor.vue'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
const loading = ref(false)
const saving = ref(false)
const form = reactive({
id: null,
title: '',
type: 'agreement',
content: '',
status: '0',
remark: ''
})
async function loadDetail() {
if (!isEdit.value) return
loading.value = true
try {
const r = await request({ url: `/business/article/${route.params.id}`, method: 'get' })
const data = (r.data && r.data.data) || r.data || {}
Object.assign(form, data)
} catch (e) {
ElMessage.error('加载失败')
} finally { loading.value = false }
}
async function save() {
if (!form.title.trim()) return ElMessage.warning('请输入标题')
if (!form.content.trim()) return ElMessage.warning('请输入正文')
saving.value = true
try {
if (isEdit.value) {
await request({ url: '/business/article', method: 'put', data: { ...form } })
ElMessage.success('保存成功')
} else {
await request({ url: '/business/article', method: 'post', data: { ...form } })
ElMessage.success('新建成功')
}
router.push('/admin/article')
} catch (e) {
ElMessage.error(e?.msg || '保存失败')
} finally { saving.value = false }
}
onMounted(loadDetail)
</script>
<style scoped>
.biz-article-edit { padding: 16px; }
.header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
.header .title { font-size: 18px; font-weight: 600; }
.form-tip { margin-left: 12px; font-size: 12px; color: #909399; }
:deep(.w-e-text-container) { background: #fff; }
</style>
@@ -0,0 +1,102 @@
<template>
<div class="page-card admin-plan">
<div class="breadcrumb">首页 / 网站管理 / 七大专项计划</div>
<!-- 筛选 -->
<el-form inline :model="q" class="filter-form">
<el-form-item label="标题">
<el-input v-model="q.title" placeholder="输入标题" clearable />
</el-form-item>
<el-form-item label="类型">
<el-select v-model="q.contentType" placeholder="全部" clearable style="width:140px">
<el-option label="富文本" value="rich" />
<el-option label="上传文件" value="file" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="q.status" placeholder="全部" clearable style="width:120px">
<el-option label="启用" value="0" />
<el-option label="停用" value="1" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">查询</el-button>
<el-button @click="reset">重置</el-button>
</el-form-item>
</el-form>
<!-- 列表 -->
<el-table :data="rows" border stripe v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="title" label="标题" min-width="220" />
<el-table-column label="类型" width="110">
<template #default="{ row }">{{ TYPE_LABEL[row.contentType] || row.contentType }}</template>
</el-table-column>
<el-table-column prop="sortOrder" label="排序" width="80" sortable />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === '0' ? 'success' : 'danger'">{{ row.status === '0' ? '启用' : '停用' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="updateTime" label="更新时间" width="170" />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="$router.push('/admin/special-plan/edit/' + row.id)">编辑</el-button>
<el-button size="small" link type="primary" :disabled="row.status !== '0'" @click="preview(row)">预览</el-button>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
v-model:current-page="q.pageNum"
v-model:page-size="q.pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="load"
@size-change="load"
/>
</div>
</div>
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue'
import request from '@/utils/request'
const TYPE_LABEL = { rich: '富文本', file: '上传文件' }
const q = reactive({ title: '', contentType: '', status: '', pageNum: 1, pageSize: 20 })
const rows = ref([])
const total = ref(0)
const loading = ref(false)
async function load() {
loading.value = true
try {
const r = await request({ url: '/business/specialPlan/list', method: 'get', params: { ...q } })
rows.value = (r.data && r.data.rows) || r.rows || []
total.value = (r.data && r.data.total) || r.total || 0
} finally { loading.value = false }
}
function reset() {
q.title = ''; q.contentType = ''; q.status = ''
q.pageNum = 1
load()
}
function preview(row) {
window.open(`/#/special-plan/${row.id}`, '_blank')
}
onMounted(load)
</script>
<style scoped>
.admin-plan { padding: 16px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.filter-form { margin-bottom: 12px; }
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
</style>
@@ -0,0 +1,124 @@
<template>
<div class="page-card biz-plan-edit">
<div class="header">
<el-button :icon="ArrowLeft" @click="$router.back()">返回</el-button>
<span class="title">{{ isEdit ? '编辑专项计划' : '新建专项计划' }}</span>
</div>
<el-form :model="form" label-width="100px" v-loading="loading">
<el-form-item label="标题" required>
<el-input v-model="form.title" placeholder="请输入专项计划名称" maxlength="200" />
</el-form-item>
<el-form-item label="内容类型" required>
<el-radio-group v-model="form.contentType" @change="onTypeChange">
<el-radio value="rich">富文本</el-radio>
<el-radio value="file">上传文件 (PDF / PNG)</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="正文" :required="form.contentType === 'rich'" v-if="form.contentType === 'rich'">
<RichEditor v-model="form.content" placeholder="请输入专项计划正文" />
</el-form-item>
<el-form-item label="文件" :required="form.contentType === 'file'" v-else>
<OssFileUploader v-model="form.fileUrl" dir="ry8080/special-plan/" accept=".pdf,.png" placeholder="点击上传 PDF / PNG" hint="仅支持 PDF 或 PNG, ≤10MB" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sortOrder" :min="0" :step="1" />
<span class="form-tip">数字越小越靠前</span>
</el-form-item>
<el-form-item label="状态" required>
<el-radio-group v-model="form.status">
<el-radio value="0">启用</el-radio>
<el-radio value="1">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="可选, 内部备注" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
<el-button @click="$router.back()">取消</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import request from '@/utils/request'
import RichEditor from '@/components/RichEditor.vue'
import OssFileUploader from '@/components/OssFileUploader.vue'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
const loading = ref(false)
const saving = ref(false)
const form = reactive({
id: null,
title: '',
contentType: 'rich',
content: '',
fileUrl: '',
sortOrder: 0,
status: '0',
remark: ''
})
async function loadDetail() {
if (!isEdit.value) return
loading.value = true
try {
const r = await request({ url: `/business/specialPlan/${route.params.id}`, method: 'get' })
const data = (r.data && r.data.data) || r.data || {}
Object.assign(form, data)
} catch (e) {
ElMessage.error('加载失败')
} finally { loading.value = false }
}
function onTypeChange(val) {
// 切换内容类型时清空另一字段, 避免脏数据
if (val === 'rich') form.fileUrl = ''
else form.content = ''
}
async function save() {
if (!form.title.trim()) return ElMessage.warning('请输入标题')
if (form.contentType === 'rich' && !form.content.trim()) return ElMessage.warning('请输入正文')
if (form.contentType === 'file' && !form.fileUrl) return ElMessage.warning('请上传文件')
saving.value = true
try {
if (isEdit.value) {
await request({ url: '/business/specialPlan', method: 'put', data: { ...form } })
ElMessage.success('保存成功')
} else {
await request({ url: '/business/specialPlan', method: 'post', data: { ...form } })
ElMessage.success('新建成功')
}
router.push('/admin/special-plan')
} catch (e) {
ElMessage.error(e?.msg || '保存失败')
} finally { saving.value = false }
}
onMounted(loadDetail)
</script>
<style scoped>
.biz-plan-edit { padding: 16px; }
.header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
.header .title { font-size: 18px; font-weight: 600; }
.form-tip { margin-left: 12px; font-size: 12px; color: #909399; }
</style>
+335 -45
View File
@@ -30,10 +30,15 @@
<!-- 右侧登录卡片 -->
<section class="login-section">
<div class="login-card">
<h1 class="login-title">账号登录</h1>
<p class="login-subtitle">请输入您的账号信息</p>
<h1 class="login-title">{{ loginMode === 'password' ? '账号登录' : '手机号登录' }}</h1>
<p class="login-subtitle">{{ loginMode === 'password' ? '请输入您的账号信息' : '请输入手机号接收验证码' }}</p>
<form id="loginForm" @submit.prevent="onSubmit">
<div class="login-tabs">
<span class="tab" :class="{ active: loginMode === 'password' }" @click="switchMode('password')">账号密码登录</span>
<span class="tab" :class="{ active: loginMode === 'sms' }" @click="switchMode('sms')">手机验证码登录</span>
</div>
<form v-if="loginMode === 'password'" id="loginForm" @submit.prevent="onSubmit">
<div class="form-group">
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
@@ -65,6 +70,29 @@
<a class="bottom-link" @click="onForgot">忘记密码</a>
</div>
</form>
<form v-else id="smsLoginForm" @submit.prevent="onSmsLoginSubmit">
<div class="form-group">
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>
<input v-model="smsForm.phone" type="text" class="form-input" placeholder="请输入手机号" maxlength="11" autocomplete="tel">
</div>
<div class="form-group sms-group">
<input v-model="smsForm.smsCode" type="text" class="form-input sms-input" placeholder="短信验证码" maxlength="6">
<button type="button" class="sms-btn" :disabled="smsSmsLocked || smsSmsCountdown > 0 || !smsForm.phone" @click="sendLoginSmsCode">
{{ smsSmsLocked ? '发送中...' : smsSmsCountdown > 0 ? `${smsSmsCountdown}s 后重试` : '获取验证码' }}
</button>
</div>
<button type="submit" class="login-btn" :disabled="smsLoginLocked">{{ smsLoginLocked ? '登录中...' : '登 录' }}</button>
<div class="form-bottom-links">
<a class="bottom-link" @click="onRegister">新用户注册</a>
<a class="bottom-link" @click="onForgot">忘记密码</a>
</div>
</form>
</div>
</section>
</main>
@@ -94,15 +122,68 @@
</div>
</div>
</div>
<!-- 忘记密码弹窗 -->
<div class="modal-overlay" :class="{ active: showForgotModal }" @click.self="closeForgot">
<div class="modal">
<h2 class="modal-title">忘记密码</h2>
<p class="modal-subtitle">通过注册时填写的手机号验证后重置密码</p>
<form @submit.prevent="onResetSubmit">
<div class="form-group">
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>
<input v-model="forgotForm.phone" type="text" class="form-input" placeholder="请输入注册时的手机号" maxlength="11" autocomplete="tel">
</div>
<div class="form-group sms-group">
<input v-model="forgotForm.smsCode" type="text" class="form-input sms-input" placeholder="短信验证码" maxlength="6">
<button type="button" class="sms-btn" :disabled="forgotSmsLocked || forgotSmsCountdown > 0 || !forgotForm.phone" @click="sendForgotSms">
{{ forgotSmsLocked ? '发送中...' : forgotSmsCountdown > 0 ? `${forgotSmsCountdown}s 后重试` : '获取验证码' }}
</button>
</div>
<div class="form-group">
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
<input v-model="forgotForm.password" type="password" class="form-input" placeholder="新密码 (6-20 位)" autocomplete="new-password">
</div>
<div class="form-group">
<svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
<input v-model="forgotForm.confirmPassword" type="password" class="form-input" placeholder="确认新密码" autocomplete="new-password">
</div>
<div class="modal-actions">
<button class="modal-btn secondary" type="button" @click="closeForgot">取消</button>
<button class="modal-btn primary" type="submit" :disabled="forgotLoading">{{ forgotLoading ? '提交中...' : '重置密码' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue'
import { reactive, ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { login, getInfo, getCaptcha } from '@/api/auth'
import request from '@/utils/request'
import { login, getInfo, getCaptcha, sendLoginSms, smsLogin } from '@/api/auth'
import { useUserStore } from '@/store/user'
import { useAsyncLock } from '@/utils/useAsyncLock'
// 图形验证码刷新 + 忘记密码短信 + 短信登录 各自独立锁 (避免重复请求)
const { locked: captchaLocked, run: runCaptchaOnce } = useAsyncLock()
const { locked: forgotSmsLocked, run: runForgotSmsOnce } = useAsyncLock()
const { locked: smsSmsLocked, run: runSmsOnce } = useAsyncLock()
const { locked: smsLoginLocked, run: runSmsLoginOnce } = useAsyncLock()
const router = useRouter()
const userStore = useUserStore()
@@ -114,6 +195,12 @@ const loading = ref(false)
const showRoleModal = ref(false)
const registerUserType = ref('')
// 登录模式: 'password' | 'sms'
const loginMode = ref('password')
const smsForm = reactive({ phone: '', smsCode: '', uuid: '' })
const smsSmsCountdown = ref(0)
let smsSmsTimer = null
const registerTypes = [
{ value: 'expert', name: '项目参与专家', desc: '负责项目方案设计与评审' },
{ value: 'executor', name: '项目执行单位(供应商)', desc: '承担项目执行,需走入库流程' },
@@ -130,16 +217,18 @@ const userTypes = [
]
async function loadCaptcha() {
try {
const res = await getCaptcha()
captchaEnabled.value = res?.captchaEnabled !== false
if (res?.uuid) {
form.uuid = res.uuid
captchaImg.value = 'data:image/png;base64,' + (res.img || '')
await runCaptchaOnce(async () => {
try {
const res = await getCaptcha()
captchaEnabled.value = res?.captchaEnabled !== false
if (res?.uuid) {
form.uuid = res.uuid
captchaImg.value = 'data:image/png;base64,' + (res.img || '')
}
} catch (e) {
captchaImg.value = ''
}
} catch (e) {
captchaImg.value = ''
}
})
}
async function onSubmit() {
@@ -154,36 +243,7 @@ async function onSubmit() {
loading.value = true
try {
const res = await login({ username: form.username, password: form.password, code: form.code || '', uuid: form.uuid || '' })
// username 推断仅作兜底, 真实 role 以 /getInfo 返回的 sys_user.role_type 为准
const fallbackRole = autoRole(form.username)
userStore.setToken(res.token)
// /login 接口只返回 token; 调 /getInfo 拿完整 user (含 accountType/parentUserId/roleType, 让 isMain/isSub/role 都从后端真实值取)
let role = fallbackRole
try {
const info = await getInfo()
const u = info.user || {}
role = u.roleType || fallbackRole
userStore.setUser({
userId: u.userId,
userName: u.userName || form.username,
nickName: u.nickName || form.username,
accountType: u.accountType || 'MAIN',
parentUserId: u.parentUserId || null,
role
})
} catch {
// /getInfo 失败时回退: 只存基本信息
userStore.setUser({
userId: res.userId,
userName: form.username,
nickName: form.username,
accountType: 'MAIN',
parentUserId: null,
role: fallbackRole
})
}
ElMessage.success(`欢迎,${form.username}${userTypes.find(u => u.value === role)?.name || role}`)
router.replace(roleHome[role] || '/leader/home')
await afterLogin(res.token, form.username, autoRole(form.username))
} catch (e) {
// 错误提示已由 utils/request.js 拦截器统一弹 (ElMessage.error), 这里只刷新验证码
loadCaptcha()
@@ -192,6 +252,97 @@ async function onSubmit() {
}
}
/**
* 登录后置: 存 token → 调 /getInfo 拿真实 user → 跳角色首页
* 密码登录和短信登录共用
*/
async function afterLogin(token, displayName, fallbackRole) {
userStore.setToken(token)
let role = fallbackRole
try {
const info = await getInfo()
const u = info.user || {}
role = u.roleType || fallbackRole
userStore.setUser({
userId: u.userId,
userName: u.userName || displayName,
nickName: u.nickName || displayName,
accountType: u.accountType || 'MAIN',
parentUserId: u.parentUserId || null,
role
})
} catch {
// /getInfo 失败时回退: 只存基本信息
userStore.setUser({
userId: null,
userName: displayName,
nickName: displayName,
accountType: 'MAIN',
parentUserId: null,
role: fallbackRole
})
}
ElMessage.success(`欢迎,${displayName}${userTypes.find(u => u.value === role)?.name || role}`)
router.replace(roleHome[role] || '/leader/home')
}
function switchMode(mode) {
if (loginMode.value === mode) return
loginMode.value = mode
// 切换时清空两条表单, 避免脏数据
if (mode === 'password') {
smsForm.phone = ''
smsForm.smsCode = ''
smsForm.uuid = ''
if (smsSmsTimer) { clearInterval(smsSmsTimer); smsSmsTimer = null }
smsSmsCountdown.value = 0
} else {
form.username = ''
form.password = ''
form.code = ''
// 切到短信登录时不需要图形验证码, 不再 loadCaptcha
}
}
async function sendLoginSmsCode() {
if (!/^1\d{10}$/.test(smsForm.phone)) {
return ElMessage.warning('请输入正确的手机号')
}
await runSmsOnce(async () => {
try {
const r = await sendLoginSms(smsForm.phone)
smsForm.uuid = r.data || ''
ElMessage.success('验证码已发送')
smsSmsCountdown.value = 60
smsSmsTimer = setInterval(() => {
smsSmsCountdown.value--
if (smsSmsCountdown.value <= 0) {
clearInterval(smsSmsTimer)
smsSmsTimer = null
}
}, 1000)
} catch (e) {
// request.js 已弹错误, 这里只重置 uuid
smsForm.uuid = ''
}
})
}
async function onSmsLoginSubmit() {
if (!/^1\d{10}$/.test(smsForm.phone)) return ElMessage.warning('请输入正确的手机号')
if (!smsForm.smsCode) return ElMessage.warning('请输入短信验证码')
if (!smsForm.uuid) return ElMessage.warning('请先获取验证码')
await runSmsLoginOnce(async () => {
try {
const res = await smsLogin({ phone: smsForm.phone, smsCode: smsForm.smsCode, uuid: smsForm.uuid })
// 短信登录后无 username, 用 phone 作为显示名; role 兜底默认 leader
await afterLogin(res.token, smsForm.phone, 'leader')
} catch (e) {
// request.js 已弹错误
}
})
}
function autoRole(username) {
const u = (username || '').toLowerCase()
if (u === 'admin' || u === 'ry') return 'admin'
@@ -241,10 +392,88 @@ function confirmRegister() {
}
function onForgot() {
ElMessage.info('请联系系统管理员重置密码')
showForgotModal.value = true
}
// ===== 忘记密码弹窗 =====
const showForgotModal = ref(false)
const forgotLoading = ref(false)
const forgotSmsCountdown = ref(0)
let forgotSmsTimer = null
const forgotForm = reactive({ phone: '', smsCode: '', password: '', confirmPassword: '' })
let forgotSmsUuid = ''
function closeForgot() {
showForgotModal.value = false
forgotForm.phone = ''
forgotForm.smsCode = ''
forgotForm.password = ''
forgotForm.confirmPassword = ''
forgotSmsUuid = ''
if (forgotSmsTimer) { clearInterval(forgotSmsTimer); forgotSmsTimer = null }
forgotSmsCountdown.value = 0
}
async function sendForgotSms() {
if (!/^1\d{10}$/.test(forgotForm.phone)) {
return ElMessage.warning('请输入正确的手机号')
}
await runForgotSmsOnce(async () => {
try {
const r = await request({ url: '/business/auth/forgotSendSms', method: 'post', data: { phone: forgotForm.phone } })
forgotSmsUuid = r.data || ''
ElMessage.success('验证码已发送')
forgotSmsCountdown.value = 60
forgotSmsTimer = setInterval(() => {
forgotSmsCountdown.value--
if (forgotSmsCountdown.value <= 0) {
clearInterval(forgotSmsTimer)
forgotSmsTimer = null
}
}, 1000)
} catch (e) {
// request.js 拦截器已弹 ElMessage.error, 这里只重置
forgotSmsUuid = ''
}
})
}
async function onResetSubmit() {
if (!/^1\d{10}$/.test(forgotForm.phone)) return ElMessage.warning('请输入正确的手机号')
if (!forgotForm.smsCode) return ElMessage.warning('请输入短信验证码')
if (!forgotForm.password || forgotForm.password.length < 6 || forgotForm.password.length > 20) {
return ElMessage.warning('密码长度 6-20 位')
}
if (forgotForm.password !== forgotForm.confirmPassword) {
return ElMessage.warning('两次密码输入不一致')
}
forgotLoading.value = true
try {
await request({
url: '/business/auth/resetPassword',
method: 'post',
data: {
phone: forgotForm.phone,
smsCode: forgotForm.smsCode,
uuid: forgotSmsUuid,
password: forgotForm.password,
confirmPassword: forgotForm.confirmPassword
}
})
ElMessage.success('密码重置成功, 请用新密码登录')
closeForgot()
} catch (e) {
// request.js 已弹错误
} finally {
forgotLoading.value = false
}
}
onMounted(() => { loadCaptcha() })
onUnmounted(() => {
if (forgotSmsTimer) clearInterval(forgotSmsTimer)
if (smsSmsTimer) clearInterval(smsSmsTimer)
})
</script>
<style>
@@ -396,6 +625,30 @@ onMounted(() => { loadCaptcha() })
margin-bottom: 28px;
}
/* 登录模式切换 tabs */
.login-page .login-tabs{
display: flex;
gap: 24px;
margin-bottom: 24px;
border-bottom: 1px solid #e5e7eb;
}
.login-page .login-tabs .tab{
padding: 8px 0;
font-size: 14px;
color: #6b7280;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.2s, border-color 0.2s;
user-select: none;
}
.login-page .login-tabs .tab:hover{ color: #1e3a8a; }
.login-page .login-tabs .tab.active{
color: #1e3a8a;
border-bottom-color: #1e3a8a;
font-weight: 500;
}
.login-page .form-group{
margin-bottom: 18px;
position: relative;
@@ -689,4 +942,41 @@ onMounted(() => { loadCaptcha() })
.modal-btn.secondary:hover { background: #ebeef5; }
.modal-btn.primary { background: var(--brand-primary, #1890ff); color: #fff; }
.modal-btn.primary:hover { background: var(--brand-primary-deep, #096dd9); }
/* ===== 忘记密码弹窗 ===== */
.modal-subtitle {
font-size: 13px;
color: #6b7280;
text-align: center;
margin: -12px 0 20px;
}
.sms-group {
display: flex;
gap: 10px;
margin-bottom: 18px;
}
.sms-group .sms-input {
flex: 1;
padding-left: 14px;
}
.sms-btn {
flex-shrink: 0;
width: 110px;
height: 44px;
border: 1px solid #d1d5db;
background: #f9fafb;
color: #1e3a8a;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.sms-btn:hover:not(:disabled) {
border-color: #1e3a8a;
background: #f3f4f6;
}
.sms-btn:disabled {
color: #9ca3af;
background: #f3f4f6;
cursor: not-allowed;
}
</style>
+31 -33
View File
@@ -38,8 +38,8 @@
<el-form-item label="短信验证码" prop="smsCode">
<div class="sms-row">
<el-input v-model="form.smsCode" placeholder="请输入收到的验证码" maxlength="6" />
<el-button class="sms-btn" :disabled="smsCountdown > 0 || !form.phone" @click="sendSms">
{{ smsCountdown > 0 ? `${smsCountdown}s 后重试` : '获取验证码' }}
<el-button class="sms-btn" :disabled="smsLocked || smsCountdown > 0 || !form.phone" @click="sendSms">
{{ smsLocked ? '发送中...' : smsCountdown > 0 ? `${smsCountdown}s 后重试` : '获取验证码' }}
</el-button>
</div>
</el-form-item>
@@ -53,7 +53,11 @@
</el-form-item>
<el-form-item>
<el-checkbox v-model="agreed">已阅读并接受 <a href="javascript:void(0)" @click="showAgreement = true">平台协议</a></el-checkbox>
<el-checkbox v-model="agreed">我已阅读并同意
<a href="#/article/agreement" target="_blank">用户协议</a>
<a href="#/article/privacy" target="_blank">隐私政策</a>
</el-checkbox>
</el-form-item>
<el-form-item>
@@ -78,16 +82,6 @@
已有账号? <a href="javascript:void(0)" @click="$router.push('/login')">立即登录</a>
</div>
</div>
<!-- 平台协议弹窗 -->
<el-dialog v-model="showAgreement" title="平台协议" width="560px">
<div class="agreement-content">
<p>1. 本平台仅提供项目撮合与流程管理服务, 不参与任何具体项目的执行.</p>
<p>2. 注册企业需提供真实有效的资质材料, 平台有权随时核查.</p>
<p>3. 平台对企业的商业信息负有保密义务.</p>
<p>4. 注册即表示同意上述条款.</p>
</div>
</el-dialog>
</div>
</template>
@@ -96,12 +90,14 @@ import { reactive, ref, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import request from '@/utils/request'
import { registerExecutor } from '@/api/public'
import { useAsyncLock } from '@/utils/useAsyncLock'
const { locked: smsLocked, run: sendSmsOnce } = useAsyncLock()
const step = ref(0)
const formRef = ref()
const submitting = ref(false)
const agreed = ref(false)
const showAgreement = ref(false)
const form = reactive({
username: '',
@@ -150,25 +146,27 @@ async function sendSms() {
if (!/^1\d{10}$/.test(form.phone)) {
return ElMessage.warning('请先输入正确的手机号')
}
try {
const resp = await request({
url: '/business/auth/registerSendSms',
method: 'post',
data: { phone: form.phone }
})
smsUuid.value = resp?.data || ''
ElMessage.success('验证码已发送')
smsCountdown.value = 60
smsTimer = setInterval(() => {
smsCountdown.value--
if (smsCountdown.value <= 0) {
clearInterval(smsTimer)
smsTimer = null
}
}, 1000)
} catch (e) {
ElMessage.warning(e?.msg || '验证码发送失败')
}
await sendSmsOnce(async () => {
try {
const resp = await request({
url: '/business/auth/registerSendSms',
method: 'post',
data: { phone: form.phone }
})
smsUuid.value = resp?.data || ''
ElMessage.success('验证码已发送')
smsCountdown.value = 60
smsTimer = setInterval(() => {
smsCountdown.value--
if (smsCountdown.value <= 0) {
clearInterval(smsTimer)
smsTimer = null
}
}, 1000)
} catch (e) {
ElMessage.warning(e?.msg || '验证码发送失败')
}
})
}
// ===== 提交 =====
+27 -29
View File
@@ -36,8 +36,8 @@
<el-form-item label="短信验证码" prop="code">
<div class="sms-row">
<el-input v-model="form.code" placeholder="请输入收到的验证码" maxlength="6" />
<el-button :disabled="smsCountdown > 0 || !form.phone" @click="sendCode">
{{ smsCountdown > 0 ? `${smsCountdown}s 后重新获取` : '获取验证码' }}
<el-button :disabled="smsLocked || smsCountdown > 0 || !form.phone" @click="sendCode">
{{ smsLocked ? '发送中...' : smsCountdown > 0 ? `${smsCountdown}s 后重新获取` : '获取验证码' }}
</el-button>
</div>
</el-form-item>
@@ -55,7 +55,11 @@
</el-form-item>
<el-form-item>
<el-checkbox v-model="agreed">已阅读并接受 <a href="javascript:void(0)" @click="showAgreement = true">隐私保护条款及授权书</a></el-checkbox>
<el-checkbox v-model="agreed">我已阅读并同意
<a href="#/article/agreement" target="_blank">用户协议</a>
<a href="#/article/privacy" target="_blank">隐私政策</a>
</el-checkbox>
</el-form-item>
<el-form-item>
@@ -72,16 +76,6 @@
</el-result>
</div>
</div>
<!-- 隐私协议弹窗 -->
<el-dialog v-model="showAgreement" title="隐私保护条款及授权书" width="600px">
<div class="agreement-content">
<p>1. 我们仅在为您提供专家注册项目评审等服务的过程中,收集您的姓名手机号工作单位职称等必要信息</p>
<p>2. 您的个人信息将严格保密,仅用于项目流程内部使用,不会向任何第三方披露</p>
<p>3. 您有权随时查看更正删除您的个人信息</p>
<p>4. 注册即表示您同意上述条款</p>
</div>
</el-dialog>
</div>
</template>
@@ -94,6 +88,9 @@ import request from '@/utils/request'
import DoctorDeptSelect from '@/components/DoctorDeptSelect.vue'
import DoctorTitleSelect from '@/components/DoctorTitleSelect.vue'
import OssImageUploader from '@/components/OssImageUploader.vue'
import { useAsyncLock } from '@/utils/useAsyncLock'
const { locked: smsLocked, run: sendSmsOnce } = useAsyncLock()
const router = useRouter()
const step = ref(0)
@@ -139,7 +136,6 @@ const rules = {
}
const agreed = ref(false)
const showAgreement = ref(false)
const smsCountdown = ref(0)
let smsTimer = null
@@ -148,21 +144,23 @@ async function sendCode() {
if (!/^1\d{10}$/.test(form.phone)) {
return ElMessage.warning('请先输入正确的手机号')
}
try {
const resp = await request({ url: '/business/auth/registerSendSms', method: 'post', data: { phone: form.phone } })
smsUuid.value = resp?.data || ''
ElMessage.success('验证码已发送')
smsCountdown.value = 60
smsTimer = setInterval(() => {
smsCountdown.value--
if (smsCountdown.value <= 0) {
clearInterval(smsTimer)
smsTimer = null
}
}, 1000)
} catch (e) {
ElMessage.warning(e?.msg || '验证码发送失败')
}
await sendSmsOnce(async () => {
try {
const resp = await request({ url: '/business/auth/registerSendSms', method: 'post', data: { phone: form.phone } })
smsUuid.value = resp?.data || ''
ElMessage.success('验证码已发送')
smsCountdown.value = 60
smsTimer = setInterval(() => {
smsCountdown.value--
if (smsCountdown.value <= 0) {
clearInterval(smsTimer)
smsTimer = null
}
}, 1000)
} catch (e) {
ElMessage.warning(e?.msg || '验证码发送失败')
}
})
}
async function onSubmit() {
+8 -2
View File
@@ -10,7 +10,7 @@
<el-form :model="form" label-width="100px"><el-form-item label="用户名"><el-input v-model="form.username" /></el-form-item><el-form-item label="密码"><el-input v-model="form.password" type="password" /></el-form-item></el-form>
</template>
<template v-else>
<el-form :model="form" label-width="100px"><el-form-item label="单位名称"><el-input v-model="form.unitName" /></el-form-item><el-form-item label="联系人"><el-input v-model="form.contact" /></el-form-item><el-form-item label="联系电话"><el-input v-model="form.phone" /></el-form-item><el-form-item label="赞助意向"><el-input v-model="form.intent" type="textarea" /></el-form-item></el-form>
<el-form :model="form" label-width="100px"><el-form-item label="单位名称"><el-input v-model="form.unitName" /></el-form-item><el-form-item label="联系人"><el-input v-model="form.contact" /></el-form-item><el-form-item label="联系电话"><el-input v-model="form.phone" /></el-form-item><el-form-item label="赞助意向"><el-input v-model="form.intent" type="textarea" /></el-form-item><el-form-item><el-checkbox v-model="agreed">我已阅读并同意 <a href="#/article/agreement" target="_blank">《用户协议》</a> 和 <a href="#/article/privacy" target="_blank">隐私政策</a></el-checkbox></el-form-item></el-form>
</template>
</div>
<div style="text-align:center;margin-top:16px">
@@ -27,9 +27,15 @@ import { registerSponsor } from '@/api/public'
import { ElMessage } from 'element-plus'
const step = ref(0)
const form = reactive({ username: '', password: '', unitName: '', contact: '', phone: '', intent: '' })
const agreed = ref(false)
const prev = () => step.value = Math.max(0, step.value - 1)
const next = async () => {
if (step.value === 1) { try { await registerSponsor(form); ElMessage.success('已提交') } catch { /* demo */ } step.value = 2; return }
if (step.value === 1) {
if (!agreed.value) return ElMessage.warning('请先阅读并同意用户协议和隐私政策')
try { await registerSponsor(form); ElMessage.success('已提交') } catch { /* demo */ }
step.value = 2
return
}
step.value++
}
</script>
+26 -21
View File
@@ -147,8 +147,8 @@
<el-form-item label="短信验证码" :required="true">
<div class="sms-row">
<el-input v-model="phoneForm.smsCode" placeholder="请输入验证码" />
<el-button class="btn-sms" :disabled="phoneSmsCountdown > 0" @click="sendSms">
{{ phoneSmsCountdown > 0 ? `${phoneSmsCountdown}s 后重试` : '发送验证码' }}
<el-button class="btn-sms" :disabled="phoneSmsLocked || phoneSmsCountdown > 0" @click="sendSms">
{{ phoneSmsLocked ? '发送中...' : phoneSmsCountdown > 0 ? `${phoneSmsCountdown}s 后重试` : '发送验证码' }}
</el-button>
</div>
</el-form-item>
@@ -174,6 +174,9 @@ import DoctorDeptSelect from '@/components/DoctorDeptSelect.vue'
import DoctorTitleSelect from '@/components/DoctorTitleSelect.vue'
import IdCardUploader from '@/components/IdCardUploader.vue'
import OssImageUploader from '@/components/OssImageUploader.vue'
import { useAsyncLock } from '@/utils/useAsyncLock'
const { locked: phoneSmsLocked, run: sendPhoneSmsOnce } = useAsyncLock()
const store = useUserStore()
const tab = ref('profile')
@@ -409,25 +412,27 @@ async function sendSms() {
if (!/^(1[0-9])\d{9}$/.test(phone)) {
return ElMessage.warning('请输入正确的手机号')
}
try {
const res = await request({
url: '/system/user/profile/sendSmsCode',
method: 'post',
params: { phone }
})
phoneSmsUuid.value = res.uuid || ''
ElMessage.success('验证码已发送')
phoneSmsCountdown.value = 60
phoneSmsTimer = setInterval(() => {
phoneSmsCountdown.value--
if (phoneSmsCountdown.value <= 0) {
clearInterval(phoneSmsTimer)
phoneSmsTimer = null
}
}, 1000)
} catch (e) {
ElMessage.error(e?.msg || '发送失败')
}
await sendPhoneSmsOnce(async () => {
try {
const res = await request({
url: '/system/user/profile/sendSmsCode',
method: 'post',
params: { phone }
})
phoneSmsUuid.value = res.uuid || ''
ElMessage.success('验证码已发送')
phoneSmsCountdown.value = 60
phoneSmsTimer = setInterval(() => {
phoneSmsCountdown.value--
if (phoneSmsCountdown.value <= 0) {
clearInterval(phoneSmsTimer)
phoneSmsTimer = null
}
}, 1000)
} catch (e) {
ElMessage.error(e?.msg || '发送失败')
}
})
}
async function onChangePhone() {
+1 -1
View File
@@ -45,7 +45,7 @@
<el-descriptions-item label="项目编号">{{ detail.projectNo }}</el-descriptions-item>
<el-descriptions-item label="项目形式">{{ detail.projectForm || '-' }}</el-descriptions-item>
<el-descriptions-item label="项目名称" :span="2">{{ detail.projectName }}</el-descriptions-item>
<el-descriptions-item label="赞助公司" :span="2">{{ detail.orgName || '-' }}</el-descriptions-item>
<el-descriptions-item label="赞助方负责人" :span="2">{{ detail.sponsorAdminUserName || '-' }}</el-descriptions-item>
<el-descriptions-item label="总场次">{{ detail.totalSessions || 0 }}</el-descriptions-item>
<el-descriptions-item label="已执行">{{ detail.doneSessions || 0 }}</el-descriptions-item>
<el-descriptions-item label="未执行">{{ detail.todoSessions || 0 }}</el-descriptions-item>
+1 -1
View File
@@ -124,7 +124,7 @@
<el-descriptions-item label="项目编号">{{ detail.projectNo }}</el-descriptions-item>
<el-descriptions-item label="项目形式">{{ detail.projectForm }}</el-descriptions-item>
<el-descriptions-item label="项目名称" :span="2">{{ detail.projectName }}</el-descriptions-item>
<el-descriptions-item label="赞助公司" :span="2">{{ detail.orgName || '-' }}</el-descriptions-item>
<el-descriptions-item label="赞助方负责人" :span="2">{{ detail.sponsorAdminUserName || '-' }}</el-descriptions-item>
<el-descriptions-item label="总场次/总期数">{{ detail.totalSessions }}/{{ detail.totalPeriods }}</el-descriptions-item>
<el-descriptions-item label="已执行">{{ detail.doneSessions || 0 }}</el-descriptions-item>
<el-descriptions-item label="未执行">{{ detail.todoSessions || 0 }}</el-descriptions-item>
@@ -29,7 +29,7 @@
<span class="info-value">{{ fmtDate(row.endTime) }}</span>
</el-form-item>
<el-form-item label="赞助公司">
<span class="info-value">{{ row.orgName || '-' }}</span>
<span class="info-value">{{ row.sponsorAdminUserName || '-' }}</span>
</el-form-item>
<el-form-item label="项目形式">
<span class="info-value">{{ row.projectForm || '-' }}</span>
+99 -41
View File
@@ -3,18 +3,13 @@
<div class="breadcrumb">首页 / 项目管理</div>
<el-form inline :model="q" class="filter-form">
<el-form-item label="赞助公司:"><el-input v-model="q.orgName" placeholder="输入赞助公司" clearable /></el-form-item>
<el-form-item label="公司类型:">
<el-select v-model="q.orgType" placeholder="全部" clearable style="width:120px">
<el-option label="赞助方" value="sponsor" />
<el-option label="执行方" value="executor" />
</el-select>
</el-form-item>
<el-form-item label="赞助方负责人:"><el-input v-model="q.sponsorAdminUserName" placeholder="输入账号/昵称" clearable /></el-form-item>
<el-form-item label="服务机构:"><el-input v-model="q.execOrgName" placeholder="输入服务机构" clearable /></el-form-item>
<el-form-item label="项目编号:"><el-input v-model="q.projectNo" placeholder="输入项目编号" clearable /></el-form-item>
<el-form-item label="项目名称:"><el-input v-model="q.projectName" placeholder="输入项目名称" clearable /></el-form-item>
<el-form-item label="项目形式:">
<el-select v-model="q.projectForm" placeholder="请选择" clearable>
<el-option label="线上" value="线上" /><el-option label="线下" value="线" />
<el-select v-model="q.projectForm" placeholder="请选择" clearable style="width:140px">
<el-option label="线上" value="线上" /><el-option label="线下" value="线" />
<el-option label="线上+线下" value="线上+线下" /><el-option label="其他" value="其他" />
</el-select>
</el-form-item>
@@ -24,12 +19,12 @@
<el-date-picker v-model="q.endTime" type="date" placeholder="结束日期" value-format="YYYY-MM-DD HH:mm:ss" />
</el-form-item>
<el-form-item label="是否结题:">
<el-select v-model="q.isFinished" placeholder="请选择" clearable>
<el-select v-model="q.isFinished" placeholder="请选择" clearable style="width:120px">
<el-option label="已结题" value="1" /><el-option label="未结题" value="0" />
</el-select>
</el-form-item>
<el-form-item label="是否结算:">
<el-select v-model="q.isSettled" placeholder="请选择" clearable>
<el-select v-model="q.isSettled" placeholder="请选择" clearable style="width:120px">
<el-option label="已结算" value="1" /><el-option label="未结算" value="0" />
</el-select>
</el-form-item>
@@ -69,14 +64,7 @@
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column prop="orgName" label="赞助公司" width="160" show-overflow-tooltip />
<el-table-column prop="orgType" label="公司类型" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.orgType === 'sponsor' ? 'success' : 'primary'" disable-transitions>
{{ row.orgType === 'sponsor' ? '赞助方' : '执行方' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="sponsorAdminUserName" label="赞助方负责人" width="160" show-overflow-tooltip />
<el-table-column prop="projectForm" label="项目形式" width="100" align="center" />
<el-table-column prop="isFinished" label="是否结题" width="100" align="center">
<template #default="{ row }">
@@ -133,11 +121,26 @@
<div style="color:#606266;font-size:13px"> <b>{{ assignBatchProjects.length }}</b> 个项目</div>
</el-form-item>
<el-form-item label="支持方">
<el-select v-model="assignForm.orgId" placeholder="请选择赞助公司" filterable :filter-method="searchSupporters" clearable style="width:100%" @change="onSupporterPick">
<el-select v-model="assignForm.sponsorAdminUserId" placeholder="请选择赞助方负责人" filterable :filter-method="searchSupporters" clearable style="width:100%" @change="onSupporterPick">
<el-option v-for="u in supporterOptions" :key="u.userId" :label="`${u.userName} (${u.nickName || ''})`" :value="u.userId" />
</el-select>
</el-form-item>
<el-divider>执行方分配</el-divider>
<!-- 单选模式: 显示项目总场次 / 总金额() vs 已分配 (实时) -->
<div v-if="!assignBatchMode" class="assign-sessions-bar">
<span>项目总场次: <b>{{ assignForm.totalSessions || 0 }}</b></span>
<span :class="{ 'is-over': singleSessionOver }">已分配场次: <b>{{ assignedSessions }}</b></span>
<span v-if="singleSessionOver" class="over-warn"> 已超出 {{ assignedSessions - assignForm.totalSessions }} </span>
<span class="sep">|</span>
<span>总金额(): <b>{{ assignForm.totalAmount || 0 }}</b></span>
<span :class="{ 'is-over': singleAmountOver }">已分配金额: <b>¥ {{ assignedAmount }}</b></span>
<span v-if="singleAmountOver" class="over-warn"> 已超出 ¥ {{ Math.round((assignedAmount - assignForm.totalAmount) * 100) / 100 }}</span>
</div>
<!-- 批量模式: 提示保存时按各项目自身总场次 / 总金额逐项校验 -->
<div v-else class="assign-sessions-bar">
<span>本次分配: <b>{{ assignedSessions }}</b> / <b>¥ {{ assignedAmount }}</b></span>
<span class="hint">(保存时按各项目自身的总场次和总金额逐项校验)</span>
</div>
<el-table :data="assignForm.execRows" border>
<el-table-column type="index" label="#" width="50" />
<el-table-column label="执行方名称" min-width="220">
@@ -264,7 +267,7 @@
</template>
<script setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { bizList, bizAdd, bizUpdate, bizDelete } from '@/api/public'
import { listExecutor, listSupporters } from '@/api/system'
@@ -275,7 +278,7 @@ import Preview from '@/components/Preview.vue'
const router = useRouter()
const route = useRoute()
const q = ref({ orgName: '', orgType: '', projectNo: '', projectName: '', projectForm: '', startTime: '', endTime: '', isFinished: '', isSettled: '', ratingScore: '' })
const q = ref({ sponsorAdminUserName: '', execOrgName: '', projectNo: '', projectName: '', projectForm: '', startTime: '', endTime: '', isFinished: '', isSettled: '', ratingScore: '' })
const rows = ref([])
const loading = ref(false)
const page = reactive({ pageNum: 1, pageSize: 10, total: 0 })
@@ -327,7 +330,7 @@ async function doDelete(row) {
}
}
function reset() { q.value = { orgName:'', orgType:'', projectNo:'', projectName:'', projectForm:'', startTime:'', endTime:'', isFinished:'', isSettled:'', ratingScore:'' }; page.pageNum = 1; load() }
function reset() { q.value = { sponsorAdminUserName:'', execOrgName:'', projectNo:'', projectName:'', projectForm:'', startTime:'', endTime:'', isFinished:'', isSettled:'', ratingScore:'' }; page.pageNum = 1; load() }
// 导出
function exportProjects() { ElMessage.info('导出项目功能开发中') }
@@ -563,20 +566,38 @@ const assignBatchProjects = ref([]) // 批量模式下的项目列表 [
const assignSubmitting = ref(false)
const assignForm = reactive({
projectId: '',
orgId: null,
orgName: '',
sponsorAdminUserId: null,
sponsorAdminUserName: '',
totalSessions: 0, // 项目总场次 (来自项目本身, 不可被分配覆盖)
totalAmount: 0, // 项目总金额(元) (来自项目本身, 不可被分配覆盖)
execRows: [], // 动态加
deadlineDays: 30
})
const supporterOptions = ref([])
let _supporterTimer = null
// 已分配场次合计 (实时)
const assignedSessions = computed(() =>
assignForm.execRows.reduce((s, r) => s + Number(r.sessions || 0), 0)
)
const singleSessionOver = computed(() =>
!assignBatchMode.value && Number(assignForm.totalSessions || 0) > 0 && assignedSessions.value > Number(assignForm.totalSessions)
)
// 已分配金额合计 (实时, 保留 2 位小数避免浮点漂移)
const assignedAmount = computed(() => {
const sum = assignForm.execRows.reduce((s, r) => s + Number(r.amount || 0), 0)
return Math.round(sum * 100) / 100
})
const singleAmountOver = computed(() =>
!assignBatchMode.value && Number(assignForm.totalAmount || 0) > 0 && assignedAmount.value > Number(assignForm.totalAmount)
)
function makeEmptyExecRow() {
return { _loading: false, _timer: null, _options: [], execUserId: null, execUserName: '', execNickName: '', execOrg: '', sessions: 0, amount: 0, remark: '' }
}
function resetAssignForm() {
Object.assign(assignForm, { projectId:'', orgId:null, orgName:'', execRows:[makeEmptyExecRow()], deadlineDays:30 })
Object.assign(assignForm, { projectId:'', sponsorAdminUserId:null, sponsorAdminUserName:'', totalSessions:0, totalAmount:0, execRows:[makeEmptyExecRow()], deadlineDays:30 })
supporterOptions.value = []
assignBatchMode.value = false
assignBatchProjects.value = []
@@ -592,8 +613,10 @@ function addExecRow() {
function onAssignProjectChange(v) {
const proj = rows.value.find(r => r.projectId === v)
if (proj) {
assignForm.orgId = proj.orgId || null
assignForm.orgName = proj.orgName || ''
assignForm.sponsorAdminUserId = proj.sponsorAdminUserId || null
assignForm.sponsorAdminUserName = proj.sponsorAdminUserName || ''
assignForm.totalSessions = Number(proj.totalSessions || 0)
assignForm.totalAmount = Number(proj.totalAmount || 0)
}
// 切换项目时重新拉已分配记录 (回显)
loadAssigns(v)
@@ -611,9 +634,9 @@ async function loadSupporters(query) {
const r = await listSupporters(params)
supporterOptions.value = r.data || []
// 已选项不在结果里时单独拉取并加首位
if (assignForm.orgId && !supporterOptions.value.find(u => u.userId === assignForm.orgId)) {
const r2 = await listSupporters({ userId: assignForm.orgId })
const sel = (r2.data || []).find(u => u.userId === assignForm.orgId)
if (assignForm.sponsorAdminUserId && !supporterOptions.value.find(u => u.userId === assignForm.sponsorAdminUserId)) {
const r2 = await listSupporters({ userId: assignForm.sponsorAdminUserId })
const sel = (r2.data || []).find(u => u.userId === assignForm.sponsorAdminUserId)
if (sel) supporterOptions.value = [sel, ...supporterOptions.value]
}
} catch (e) {
@@ -627,7 +650,7 @@ function searchSupporters(q) { loadSupporters(q) }
// 选中支持方后回填名称
function onSupporterPick(userId) {
const u = supporterOptions.value.find(x => x.userId === userId)
if (u) assignForm.orgName = u.nickName || u.userName
if (u) assignForm.sponsorAdminUserName = u.nickName || u.userName
}
// 拉取项目已分配的执行方
@@ -698,8 +721,34 @@ async function submitAssign() {
if (!assignBatchMode.value && !assignForm.projectId) return ElMessage.warning('请选择项目')
const valid = assignForm.execRows.filter(r => r.execUserId && Number(r.sessions) > 0)
if (!valid.length) return ElMessage.warning('请至少选择 1 个执行方并填写场次')
const amount = valid.reduce((s, r) => s + Number(r.amount || 0), 0)
const amount = Math.round(valid.reduce((s, r) => s + Number(r.amount || 0), 0) * 100) / 100
const sessions = valid.reduce((s, r) => s + Number(r.sessions || 0), 0)
// 校验: 各执行方分配场次 / 金额总和 <= 项目总场次 / 总金额
// (项目自身的 total_sessions / total_amount 是创建时定的, 不再被分配覆盖)
if (assignBatchMode.value) {
for (const p of assignBatchProjects.value) {
const proj = rows.value.find(r => r.projectId === p.projectId)
const capS = proj ? Number(proj.totalSessions || 0) : 0
const capA = proj ? Number(proj.totalAmount || 0) : 0
if (capS > 0 && sessions > capS) {
return ElMessage.warning(`项目 ${p.projectNo} 已分配 ${sessions} 场, 超过总场次 ${capS}, 请调整`)
}
if (capA > 0 && amount > capA) {
return ElMessage.warning(`项目 ${p.projectNo} 已分配 ¥${amount}, 超过总金额 ¥${capA}, 请调整`)
}
}
} else {
const capS = Number(assignForm.totalSessions || 0)
const capA = Number(assignForm.totalAmount || 0)
if (capS > 0 && sessions > capS) {
return ElMessage.warning(`已分配 ${sessions} 场, 超过项目总场次 ${capS}, 请调整`)
}
if (capA > 0 && amount > capA) {
return ElMessage.warning(`已分配 ¥${amount}, 超过项目总金额 ¥${capA}, 请调整`)
}
}
const assignBody = valid.map(r => ({
execUserId: r.execUserId,
execUserName: r.execUserName,
@@ -718,10 +767,8 @@ async function submitAssign() {
try {
await bizUpdate('project', {
projectId: p.projectId,
orgId: assignForm.orgId,
orgName: assignForm.orgName,
totalAmount: amount,
totalSessions: sessions,
sponsorAdminUserId: assignForm.sponsorAdminUserId,
sponsorAdminUserName: assignForm.sponsorAdminUserName,
submitDeadlineDays: assignForm.deadlineDays,
})
await request({
@@ -740,10 +787,8 @@ async function submitAssign() {
// 单条模式
await bizUpdate('project', {
projectId: assignForm.projectId,
orgId: assignForm.orgId,
orgName: assignForm.orgName,
totalAmount: amount,
totalSessions: sessions,
sponsorAdminUserId: assignForm.sponsorAdminUserId,
sponsorAdminUserName: assignForm.sponsorAdminUserName,
submitDeadlineDays: assignForm.deadlineDays,
})
await request({
@@ -830,5 +875,18 @@ watch(() => route.path + (route.query._t || ''), () => load())
.score-item { display: flex; align-items: center; gap: 8px; }
.score-item span { width: 70px; color: #606266; font-size: 13px; }
/* 分配 dialog 总场次条 */
.assign-sessions-bar {
display: flex; align-items: center; gap: 18px;
margin: -4px 0 12px; padding: 10px 14px;
background: #f5f7fa; border: 1px solid #ebeef5; border-radius: 4px;
font-size: 13px; color: #606266;
}
.assign-sessions-bar b { color: #303133; font-size: 14px; margin: 0 2px; }
.assign-sessions-bar .is-over b { color: #f56c6c; }
.assign-sessions-bar .over-warn { color: #f56c6c; font-weight: 600; }
.assign-sessions-bar .hint { color: #909399; font-size: 12px; }
.assign-sessions-bar .sep { color: #dcdfe6; margin: 0 4px; }
</style>
+45 -142
View File
@@ -69,7 +69,7 @@
</el-col>
<el-col :span="12">
<el-form-item label="支持单位">
<el-select v-model="form.orgId" placeholder="请选择赞助公司" filterable clearable style="width:100%" @change="onSupporterPick">
<el-select v-model="form.sponsorAdminUserId" placeholder="请选择赞助方负责人" filterable clearable style="width:100%" @change="onSupporterPick">
<el-option v-for="u in supporterOptions" :key="u.userId"
:label="`${u.userName}${u.nickName ? ' (' + u.nickName + ')' : ''}`"
:value="u.userId" />
@@ -104,37 +104,21 @@
<!-- ========== 第三区块公告文件 ========== -->
<div class="new-card-title">公告文件</div>
<p class="hint-text">*勾选公告类型后上传对应文件发布公告时可同时发布多个类型</p>
<p class="hint-text">*支持 PDF / 图片, 单文件 10MB; 名称规则: 公告文件类型 + 项目编号后两段 + 项目名称</p>
<div class="notice-list">
<div v-for="(n, idx) in form.notices" :key="idx" class="notice-row">
<el-checkbox v-model="n.enabled" :label="n.key" class="notice-check">
{{ n.label }}
</el-checkbox>
<el-upload
v-if="n.enabled"
<span class="notice-label">{{ n.label }}</span>
<oss-file-uploader
class="notice-uploader"
:show-file-list="true"
:file-list="getFileList(n)"
:before-upload="(file) => beforeUpload(file, n)"
:http-request="(opts) => httpUpload(opts, n, idx)"
:on-remove="(file) => onRemove(n, file)"
:on-preview="(file) => onPreview(file)"
accept=".pdf,.doc,.docx"
drag
action="#"
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">
将文件拖到此处,或<em>点击上传</em>
</div>
<div class="el-upload__tip" slot="tip">
支持 PDF / Word,单文件 ≤ 20MB
</div>
</el-upload>
<div v-else class="notice-disabled">勾选「{{ n.label }}」后可上传文件</div>
v-model="n.url"
:dir="`ry8080/project/${n.key}/`"
accept=".pdf,.png,.jpg,.jpeg,.gif,.webp"
:placeholder="`点击上传${n.label}`"
hint="支持 PDF / 图片"
block
/>
</div>
</div>
<p class="hint-text">*名称规则:公告文件类型+取项目编号的后两段+项目名称</p>
</el-form>
<!-- 底部按钮 -->
@@ -152,8 +136,8 @@ import { useRoute, useRouter } from 'vue-router'
import { bizAdd, bizUpdate, bizGet } from '@/api/public'
import { listExecutor, listSupporters } from '@/api/system'
import { ElMessage, ElMessageBox } from 'element-plus'
import { uploadToOss } from '@/utils/oss'
import request from '@/utils/request'
import OssFileUploader from '@/components/OssFileUploader.vue'
const router = useRouter()
const route = useRoute()
@@ -161,12 +145,10 @@ const isEdit = !!route.params.projectId
const projectId = route.params.projectId || null
const formRef = ref(null)
const submitting = ref(false)
const uploadingIdx = ref(-1)
const currentUploadN = ref(null)
const fileInputRef = ref(null)
function defaultRoleRow() { return { role: '', customName: '', amount: 0 } }
function defaultNotice(key, label) { return { key, label, url: '', enabled: false, name: '' } }
function defaultNotice(key, label) { return { key, label, url: '', name: '' } }
// 支持方候选 (sponsor 角色)
const supporterOptions = ref([])
@@ -175,19 +157,24 @@ const form = reactive({
projectName: '', projectNo: '', projectForm: '',
totalSessions: 0, totalAmount: 0, manageFee: 0,
startTime: '', endTime: '',
// 赞助公司 (TODO 后续: 下拉源应改为 biz_org,orgType='sponsor' 列表, 当前是 sys_user)
orgId: null,
orgName: '',
// 赞助方负责人 (sys_user.role_type='sponsor', 原 org_id/org_name 字段已重命名)
sponsorAdminUserId: null,
sponsorAdminUserName: '',
// 提交截止(天)
submitDeadlineDays: 30,
roleRows: Array(6).fill(0).map(() => defaultRoleRow()),
// 公告附件(每个 notice 含 enabled/type/label/url/name)
// enabled: 是否启用该公告类型(对应原型 checkbox 邀请函/支持函/通知/日程)
roleRows: [
{ role: '主席', customName: '', amount: 0 },
{ role: '主持', customName: '', amount: 0 },
{ role: '讲者', customName: '', amount: 0 },
{ role: '点评/评审', customName: '', amount: 0 },
{ role: '讨论', customName: '', amount: 0 }
],
// 公告附件 (key/label/url/name, 没有 url 即为空, 不参与提交)
notices: [
{ key: 'invitation', label: '邀请函', enabled: false, url: '', name: '' },
{ key: 'supportLetter', label: '支持函', enabled: false, url: '', name: '' },
{ key: 'notice', label: '通知', enabled: false, url: '', name: '' },
{ key: 'schedule', label: '日程', enabled: false, url: '', name: '' }
{ key: 'invitation', label: '邀请函', url: '', name: '' },
{ key: 'supportLetter', label: '支持函', url: '', name: '' },
{ key: 'notice', label: '通知', url: '', name: '' },
{ key: 'schedule', label: '日程', url: '', name: '' }
]
})
@@ -235,8 +222,8 @@ async function loadProject() {
form.manageFee = p.manageFee || 0
form.startTime = p.startTime || ''
form.endTime = p.endTime || ''
form.orgId = p.orgId || null
form.orgName = p.orgName || ''
form.sponsorAdminUserId = p.sponsorAdminUserId || null
form.sponsorAdminUserName = p.sponsorAdminUserName || ''
form.submitDeadlineDays = p.submitDeadlineDays || 30
// 公告文件反显: 优先 publicityFiles JSON 数组, 否则兼容旧字段
const files = []
@@ -248,7 +235,7 @@ async function loadProject() {
// 按 type 反显到对应 notice 行
for (const n of form.notices) {
const m = files.find(f => f.type === n.key)
if (m) { n.url = m.url || ''; n.name = m.name || ''; n.enabled = !!m.url }
if (m) { n.url = m.url || ''; n.name = m.name || '' }
}
} else {
// 兼容老数据
@@ -262,7 +249,6 @@ async function loadProject() {
for (const n of form.notices) {
n.url = noticeMap[n.key] || ''
n.name = n.url ? (n.url.split('/').pop() || '') : ''
n.enabled = !!n.url
}
}
// 加载已有执行方分配 (暂不实现, 留 hook)
@@ -284,7 +270,7 @@ async function loadSupporters(query = '') {
function onSupporterPick(userId) {
const u = supporterOptions.value.find(x => x.userId === userId)
if (u) form.orgName = u.userName || ''
if (u) form.sponsorAdminUserName = u.userName || ''
}
@@ -294,81 +280,6 @@ onMounted(() => {
loadProject()
loadSupporters('')
})
function beforeUpload(file, n) {
// 文件大小校验: 限制 20MB
const maxSize = 20 * 1024 * 1024
if (file.size > maxSize) {
ElMessage.error('文件大小不能超过 20MB')
return false
}
// 文件类型校验
const accept = ['.pdf', '.doc', '.docx', '.png', '.jpg', '.jpeg']
const ext = '.' + file.name.split('.').pop().toLowerCase()
if (!accept.includes(ext)) {
ElMessage.error('仅支持 PDF/Word/图片格式')
return false
}
return true
}
async function httpUpload(opts, n, idx) {
const file = opts.file
uploadingIdx.value = idx
currentUploadN.value = n
const dir = 'ry8080/project/' + n.key + '/'
try {
const url = await uploadToOss(file, dir)
n.url = url
n.name = file.name
n.enabled = true
ElMessage.success(`${n.label} 上传成功`)
} catch (err) {
ElMessage.error(`上传失败: ${err.message || err}`)
n.url = ''
n.name = ''
} finally {
uploadingIdx.value = -1
currentUploadN.value = null
}
}
// el-upload 内置 file-list 需要从 n.url 转换成 {name, url} 对象
function getFileList(n) {
if (!n.url) return []
// 从 url 截取文件名
const url = n.url
const name = url.split('/').pop() || `${n.label}.pdf`
return [{ name, url }]
}
function onPreview(file) {
if (file.url) window.open(file.url, '_blank')
}
function onRemove(n, file) {
n.url = ''
}
function previewFile(n) {
if (n.url) window.open(n.url, '_blank')
}
function downloadFile(n) {
if (!n.url) return
// 触发浏览器下载
const a = document.createElement('a')
a.href = n.url
a.download = n.name || (n.label + '.pdf')
a.target = '_blank'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
function removeNotice(idx) {
const n = form.notices[idx]
if (!n) return
ElMessageBox.confirm(`确定删除"${n.label}"附件?`, '提示', { type: 'warning' })
.then(() => { n.url = ''; ElMessage.success('已删除') })
.catch(() => {})
}
function goBack() {
// 带 _t 强制 Projects.vue watch 触发 reload, 即便路由一样也重载
router.push({ path: '/manager/projects', query: { _t: Date.now() } })
@@ -390,8 +301,8 @@ async function submit(mode = 'save') {
manageFee: form.manageFee,
startTime: form.startTime,
endTime: form.endTime,
orgId: form.orgId,
orgName: form.orgName,
sponsorAdminUserId: form.sponsorAdminUserId,
sponsorAdminUserName: form.sponsorAdminUserName,
submitDeadlineDays: form.submitDeadlineDays
}
// 公告文件以 publicityFiles JSON 数组存储 (替代旧 invitationUrl/supportLetterUrl/noticeUrl/scheduleUrl)
@@ -473,30 +384,22 @@ async function submit(mode = 'save') {
.role-custom { flex: 1; max-width: 180px; }
.role-amount { width: 180px; }
.row-actions { display: flex; gap: 8px; }
.notice-list { display: flex; flex-direction: column; gap: 12px; }
.notice-row { display: flex; align-items: stretch; gap: 16px; margin-bottom: 16px; }
.notice-list { display: flex; flex-direction: column; gap: 10px; }
.notice-row { display: flex; align-items: stretch; gap: 12px; margin-bottom: 12px; }
.notice-label {
width: 100px;
width: 90px;
display: flex; align-items: center; justify-content: flex-end;
color: #606266; font-size: 14px;
}
.notice-uploader { flex: 1; }
.notice-uploader :deep(.el-upload) { width: 100%; }
.notice-uploader :deep(.el-upload-dragger) {
padding: 12px 16px;
display: flex; align-items: center; gap: 12px;
}
.notice-uploader :deep(.el-upload-dragger .el-upload__text) {
font-size: 13px; color: #606266;
}
.notice-uploader :deep(.el-upload-dragger .el-upload__tip) {
font-size: 12px; color: #909399; margin-top: 2px;
}
.notice-uploader :deep(.el-upload__tip) { margin-top: 0; }
.notice-uploader :deep(.el-upload-list) { margin: 0; }
.notice-actions { display: flex; flex-direction: column; justify-content: center; gap: 4px; min-width: 70px; }
.notice-actions .el-button { padding: 2px 6px; font-size: 12px; }
.notice-disabled { flex: 1; color: #c0c4cc; font-size: 13px; padding: 8px 0; }
/* 紧凑版 (覆盖 OssFileUploader 默认尺寸) */
.notice-uploader :deep(.ht-file-upload) { padding: 6px 12px; min-height: 40px; gap: 8px; }
.notice-uploader :deep(.placeholder-text) { font-size: 13px; }
.notice-uploader :deep(.placeholder-hint) { font-size: 11px; }
.notice-uploader :deep(.file-name) { font-size: 13px; }
.notice-uploader :deep(.file-type) { font-size: 11px; }
.notice-uploader :deep(.file-icon) { font-size: 18px; }
.notice-uploader :deep(.file-icon svg) { width: 18px; height: 18px; }
/* 底部按钮 */
.form-actions { display: flex; justify-content: center; gap: 16px; padding: 20px 0 4px; border-top: 1px solid #f0f0f0; margin-top: 8px; }
+374
View File
@@ -0,0 +1,374 @@
<template>
<div class="detail-page">
<header class="top-nav" :class="topNavClass">
<a class="logo" title="返回首页" @click.prevent="goHome">
<div class="logo-icon"></div>
<div class="logo-text">
<span class="logo-title">北京整合医学学会</span>
<span class="logo-subtitle">Beijing Association of Holistic Integrative Medicine</span>
</div>
</a>
<ul class="nav-list">
<li class="nav-item"><a class="nav-link" @click.prevent="goHome">年度项目规划</a></li>
<li class="nav-item"><a class="nav-link" @click.prevent="goPublicity">项目公示</a></li>
</ul>
<div class="top-tools">
<template v-if="!loggedIn">
<span class="login-btn" @click="goLogin">登录</span>
</template>
<template v-else>
<el-dropdown trigger="click" @command="onUserCmd">
<a class="user-link" @click.prevent>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
<span>{{ userName }}</span>
</a>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="account">我的主页</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</div>
</header>
<main class="container">
<article class="article-card" v-loading="loading">
<header class="article-head">
<h1 class="article-title">{{ article.title || (type === 'agreement' ? '用户服务协议' : '隐私政策') }}</h1>
<div class="article-meta" v-if="article.updateTime">最后更新: {{ fmt(article.updateTime) }}</div>
</header>
<div class="article-body" v-html="article.content || '<p class=\'empty\'>暂无内容</p>'" />
</article>
</main>
<footer class="footer">
<div class="container">
<div class="footer-main">
<div class="footer-brand">
<div class="brand-row">
<div class="footer-logo-icon"></div>
<div>
<div class="footer-brand-name">北京整合医学学会</div>
<div class="footer-brand-en">Beijing Association of Holistic Integrative Medicine</div>
</div>
</div>
<p class="footer-desc">依托"健康中国2030"战略,整合医学资源,推动健康科普与公益事业,助力全民健康素养提升,共建共享健康中国</p>
</div>
<div class="footer-col">
<h4>快速导航</h4>
<a @click.prevent="goHome">首页</a>
<a @click.prevent="goHome">年度项目规划</a>
<a @click.prevent="goPublicity">项目公示</a>
</div>
<div class="footer-col">
<h4>学会项目</h4>
<a>百姓巡常行</a>
<a>专病联盟暨全国专家智库</a>
<a>乡村幸福安康</a>
<a>临床科研资助计划</a>
</div>
<div class="footer-col">
<h4>联系方式</h4>
<p>电话: 010-82089470</p>
<p>邮箱: contactus@bahim.org.cn</p>
<p>地址: 北京市海淀区知春路1号学院国际大厦908-2</p>
<p>邮编: 100083</p>
</div>
<div class="footer-qr">
<div class="qr-image">
<svg width="52" height="52" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 11h8V3H3v8zm2-6h4v4H5V5zm8-2v8h8V3h-8zm6 6h-4V5h4v4zM3 21h8v-8H3v8zm2-6h4v4H5v-4zm13-2h-2v2h2v-2zm-2 2h-2v2h2v-2zm2 2h-2v2h2v-2zm2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm0 0h-2v2h2v-2z"/>
</svg>
</div>
<div class="qr-label">公众号二维码</div>
</div>
</div>
<div class="footer-bottom">
<span>© 北京整合医学学会 BAHIM</span>
<span>京ICP备2020035479号-1 &nbsp;&nbsp; 京公网安备11010802034820</span>
</div>
</div>
</footer>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { logout as logoutApi } from '@/api/auth'
import request from '@/utils/request'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const isScrolled = ref(false)
const topNavClass = computed(() => isScrolled.value ? 'is-scrolled' : '')
const loggedIn = computed(() => !!userStore.token)
const userName = computed(() => userStore.user?.userName || '用户')
const article = ref({})
const loading = ref(false)
const type = ref('')
async function load() {
const t = route.params.type
type.value = t
if (!t) return
loading.value = true
try {
const r = await request({ url: `/business/public/article/${t}`, method: 'get' })
article.value = (r.data && r.data.data) || r.data || {}
} catch (e) {
article.value = {}
} finally { loading.value = false }
}
function fmt(d) {
if (!d) return ''
const date = new Date(d)
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function goHome() { router.push('/') }
function goPublicity() { router.push('/publicity') }
function goLogin() { router.push('/login') }
async function goLogout() { try { await logoutApi() } catch {}; userStore.logout(); router.replace('/login') }
async function onUserCmd(cmd) {
if (cmd === 'logout') return goLogout()
if (cmd === 'account') {
const r = (userStore.user?.role || '')
const map = { admin: '/leader/home', leader: '/leader/home', manager: '/manager/workbench', doctor: '/doctor/home', executor: '/executor/meetings', sponsor: '/sponsor/home' }
router.push(map[r] || '/leader/home')
}
}
function handleScroll() { isScrolled.value = window.scrollY > 10 }
onMounted(() => {
window.addEventListener('scroll', handleScroll, { passive: true })
load()
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', handleScroll)
})
watch(() => route.params.type, load)
</script>
<style scoped>
/* ========== 基础 ========== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
color: #1f2937;
background: #f5f6f8;
min-width: 1354px;
line-height: 1.6;
}
a { color: inherit; text-decoration: none; }
/* ========== 顶部导航 ========== */
.top-nav {
position: sticky;
top: 0;
z-index: 1000;
height: 72px;
padding: 0 60px;
background: var(--brand-primary);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
transition: box-shadow 0.3s;
}
.top-nav.is-scrolled { box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25); }
.logo { display: flex; align-items: center; gap: 12px; }
.logo-icon {
width: 36px; height: 36px;
background: #fff;
color: var(--brand-primary);
display: flex; align-items: center; justify-content: center;
font-size: 18px; font-weight: 600;
}
.logo-text { display: flex; flex-direction: column; line-height: 1.2; }
.logo-title { font-size: 16px; font-weight: 600; color: #fff; letter-spacing: 0.5px; }
.logo-subtitle { font-size: 11px; color: rgba(255, 255, 255, 0.6); margin-top: 2px; letter-spacing: 0.3px; }
.nav-list {
flex: 1; display: flex; align-items: center; justify-content: center;
gap: 36px; list-style: none;
}
.nav-item { position: relative; height: 72px; display: flex; align-items: center; }
.nav-link {
font-size: 15px; font-weight: 500; color: rgba(255, 255, 255, 0.85);
cursor: pointer; transition: color 0.25s; position: relative;
height: 72px; display: flex; align-items: center;
}
.nav-link::after {
content: ''; position: absolute; left: 50%; bottom: 0;
width: 0; height: 2px; background: #fff;
transform: translateX(-50%); transition: width 0.3s ease;
}
.nav-item:hover .nav-link, .nav-link.active { color: #fff; }
.nav-item:hover .nav-link::after, .nav-link.active::after { width: 100%; }
.top-tools { display: flex; align-items: center; gap: 16px; }
.login-btn {
padding: 7px 20px;
background: #fff;
color: var(--brand-primary);
font-size: 13px; font-weight: 500;
cursor: pointer; transition: background 0.2s; letter-spacing: 1px;
}
.login-btn:hover { background: #f3f4f6; }
.user-link {
display: inline-flex; align-items: center; gap: 6px;
color: #fff; font-size: 14px; cursor: pointer;
}
/* ========== 主体 ========== */
.container {
max-width: 1354px;
margin: 0 auto;
padding: 40px 60px 60px; /* 顶部加 40px 留白, 拉开与 sticky navcat 的距离 */
}
.back-link {
display: inline-flex; align-items: center; gap: 6px;
margin: 28px 0 20px;
font-size: 13px; color: #6b7280;
cursor: pointer; transition: color 0.2s;
}
.back-link:hover { color: var(--brand-primary); }
/* ========== 文章卡片 ========== */
.article-card {
background: #fff;
border: 1px solid #e5e7eb;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
padding: 56px 80px 64px;
min-height: 480px;
}
.article-head {
text-align: center;
border-bottom: 1px solid #eef0f3;
padding-bottom: 28px;
margin-bottom: 32px;
}
.article-title {
font-size: 26px;
font-weight: 700;
color: #1f2937;
letter-spacing: 1px;
line-height: 1.4;
}
.article-meta {
margin-top: 12px;
font-size: 13px;
color: #9ca3af;
}
.article-body {
font-size: 15px;
line-height: 2;
color: #374151;
}
.article-body :deep(h1),
.article-body :deep(h2),
.article-body :deep(h3) {
font-weight: 700;
color: #1f2937;
margin: 24px 0 14px;
line-height: 1.5;
}
.article-body :deep(h1) { font-size: 22px; text-align: center; }
.article-body :deep(h2) { font-size: 18px; }
.article-body :deep(h3) { font-size: 16px; }
.article-body :deep(p) {
margin: 10px 0;
text-indent: 2em;
text-align: justify;
}
.article-body :deep(ul),
.article-body :deep(ol) {
margin: 12px 0 12px 2em;
}
.article-body :deep(li) { margin: 6px 0; }
.article-body :deep(.empty) {
text-align: center;
color: #c0c4cc;
text-indent: 0;
padding: 60px 0;
}
/* ========== 底部 ========== */
.footer {
background: var(--brand-primary-darker);
color: rgba(255, 255, 255, 0.65);
padding: 48px 0 0;
}
.footer-main {
display: grid;
grid-template-columns: 1.4fr 1fr 1fr 1.2fr auto;
gap: 48px;
padding-bottom: 36px;
}
.footer-brand .brand-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.footer-logo-icon {
width: 36px; height: 36px;
background: #fff;
color: var(--brand-primary);
display: flex; align-items: center; justify-content: center;
font-size: 18px; font-weight: 600; flex-shrink: 0;
}
.footer-brand-name { font-size: 16px; font-weight: 600; color: #fff; letter-spacing: 1px; }
.footer-brand-en { font-size: 10px; color: rgba(255, 255, 255, 0.4); letter-spacing: 0.5px; margin-top: 2px; }
.footer-desc { font-size: 13px; line-height: 1.9; color: rgba(255, 255, 255, 0.55); }
.footer-col h4 {
font-size: 14px; font-weight: 600; color: #fff;
letter-spacing: 1px; margin-bottom: 16px; padding-bottom: 10px;
position: relative;
}
.footer-col h4::after {
content: ''; position: absolute; left: 0; bottom: 0;
width: 24px; height: 2px; background: #93c5fd;
}
.footer-col a, .footer-col p {
display: block; font-size: 13px; color: rgba(255, 255, 255, 0.6);
line-height: 2.1; transition: color 0.2s;
}
.footer-col a { cursor: pointer; }
.footer-col a:hover { color: #fff; }
.footer-qr { text-align: center; }
.qr-image {
width: 100px; height: 100px;
background: #fff;
display: flex; align-items: center; justify-content: center;
color: #1f2937;
}
.qr-label { font-size: 12px; color: rgba(255, 255, 255, 0.5); margin-top: 10px; }
.footer-bottom {
border-top: 1px solid rgba(255, 255, 255, 0.12);
padding: 18px 0;
display: flex; justify-content: space-between;
font-size: 12px; color: rgba(255, 255, 255, 0.4);
}
@media (max-width: 900px) {
.container { padding: 0 24px 40px; }
.article-card { padding: 32px 24px; }
}
</style>
+31 -78
View File
@@ -77,83 +77,16 @@
<div class="specialty-inner">
<div class="section-title">七大专项计划</div>
<div class="specialty-list">
<div class="specialty-card">
<div class="specialty-no">01</div>
<div v-for="(p, i) in specialPlans" :key="p.id" class="specialty-card" @click="openPlan(p.id)">
<div class="specialty-no">{{ String(i + 1).padStart(2, '0') }}</div>
<div class="specialty-body">
<h3 class="specialty-title">规范化诊疗及医疗质量提升专项计划(2026)</h3>
<p class="specialty-desc">聚焦临床诊疗规范制定与质量提升,推动各级医疗机构诊疗标准化同质化</p>
<h3 class="specialty-title">{{ p.title }}</h3>
<div class="specialty-action">
<span class="specialty-link">查看详情</span>
<span class="specialty-link">项目提案</span>
</div>
</div>
</div>
<div class="specialty-card">
<div class="specialty-no">02</div>
<div class="specialty-body">
<h3 class="specialty-title">科研创新专项行动计划(2026-2030)</h3>
<p class="specialty-desc">支持医学科研创新,推动整合医学研究成果转化与产业化应用,构建协同创新生态</p>
<div class="specialty-action">
<span class="specialty-link">查看详情</span>
<span class="specialty-link">项目提案</span>
</div>
</div>
</div>
<div class="specialty-card">
<div class="specialty-no">03</div>
<div class="specialty-body">
<h3 class="specialty-title">医疗卫生人才培育专项行动计划(2026-2030)</h3>
<p class="specialty-desc">建立多层次医学人才培养体系,重点加强基层医疗人才与跨学科复合型人才建设</p>
<div class="specialty-action">
<span class="specialty-link">查看详情</span>
<span class="specialty-link">项目提案</span>
</div>
</div>
</div>
<div class="specialty-card">
<div class="specialty-no">04</div>
<div class="specialty-body">
<h3 class="specialty-title">医院管理及高质量发展促进专项计划(2026-2030)</h3>
<p class="specialty-desc">推动医院管理现代化,提升运营效率与服务水平,助力公立医院高质量发展</p>
<div class="specialty-action">
<span class="specialty-link">查看详情</span>
<span class="specialty-link">项目提案</span>
</div>
</div>
</div>
<div class="specialty-card">
<div class="specialty-no">05</div>
<div class="specialty-body">
<h3 class="specialty-title">社会公益与可及性提升专项计划(2026-2030)</h3>
<p class="specialty-desc">聚焦医疗服务的可及性与公平性,开展公益救助基层帮扶与健康科普活动</p>
<div class="specialty-action">
<span class="specialty-link">查看详情</span>
<span class="specialty-link">项目提案</span>
</div>
</div>
</div>
<div class="specialty-card">
<div class="specialty-no">06</div>
<div class="specialty-body">
<h3 class="specialty-title">政学协作综合项目专项计划(2026-2030)</h3>
<p class="specialty-desc">搭建政府与学术机构协同平台,推动政策研究与决策支持,促进政产学研用深度融合</p>
<div class="specialty-action">
<span class="specialty-link">查看详情</span>
<span class="specialty-link">项目提案</span>
</div>
</div>
</div>
<div class="specialty-card">
<div class="specialty-no">07</div>
<div class="specialty-body">
<h3 class="specialty-title">组织建设与内部治理专项计划(2026-2030)</h3>
<p class="specialty-desc">完善学会组织架构,健全内部治理机制,提升规范化专业化信息化管理水平</p>
<div class="specialty-action">
<span class="specialty-link">查看详情</span>
<span class="specialty-link">项目提案</span>
</div>
</div>
</div>
<div v-if="!specialPlans.length" class="specialty-empty">暂无专项计划</div>
</div>
</div>
</section>
@@ -215,11 +148,26 @@ import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/user'
import { logout as logoutApi } from '@/api/auth'
import request from '@/utils/request'
const router = useRouter()
const userStore = useUserStore()
const isScrolled = ref(false)
const specialPlans = ref([])
async function loadSpecialPlans() {
try {
const r = await request({ url: '/business/public/specialPlan/list', method: 'get' })
specialPlans.value = (r.data && r.data.data) || r.data || []
} catch (e) {
specialPlans.value = []
}
}
function openPlan(id) {
window.open(`/#/special-plan/${id}`, '_blank')
}
const topNavClass = computed(() => isScrolled.value ? 'is-scrolled' : '')
const loggedIn = computed(() => !!userStore.token)
const userName = computed(() => userStore.user?.userName || '用户')
@@ -266,12 +214,6 @@ const planSvg = `<svg viewBox="0 150 1200 370" xmlns="http://www.w3.org/2000/svg
<g transform="translate(904 362)"><polygon points="-30,0 -15,-26 15,-26 30,0 15,26 -15,26" fill="url(#hexGrad)"/><text y="6" font-size="19">07</text></g>
<g transform="translate(986 468)"><polygon points="-34,0 -17,-29 17,-29 34,0 17,29 -17,29" fill="url(#hexGrad)"/><text y="7" font-size="22">08</text></g>
</g>
<g transform="translate(600 360)">
<circle cx="0" cy="0" r="40" fill="#fff" stroke="#e0e7ff" stroke-width="2"/>
<circle cx="0" cy="0" r="30" fill="url(#centerGrad)"/>
<rect x="-16" y="-5" width="32" height="10" fill="#fff"/>
<rect x="-5" y="-16" width="10" height="32" fill="#fff"/>
</g>
<text x="600" y="430" fill="var(--brand-primary)" text-anchor="middle" font-family="Microsoft YaHei, sans-serif" font-size="32" font-weight="bold" letter-spacing="6">2025-2030年</text>
<line x1="540" y1="447" x2="600" y2="447" stroke="var(--brand-primary)" stroke-width="1.5" opacity="0.4"/>
<line x1="600" y1="447" x2="660" y2="447" stroke="var(--brand-primary)" stroke-width="1.5" opacity="0.4"/>
@@ -291,7 +233,10 @@ function handleScroll() {
isScrolled.value = window.scrollY > 10
}
onMounted(() => window.addEventListener('scroll', handleScroll, { passive: true }))
onMounted(() => {
window.addEventListener('scroll', handleScroll, { passive: true })
loadSpecialPlans()
})
onBeforeUnmount(() => window.removeEventListener('scroll', handleScroll))
function onHome() { isScrolled.value = false }
@@ -632,8 +577,16 @@ a { color: inherit; text-decoration: none; }
background: #fff;
border: 1px solid #e5e7eb;
display: flex;
cursor: pointer;
transition: border-color 0.2s, transform 0.2s;
}
.specialty-empty {
grid-column: 1 / -1;
text-align: center;
color: #9ca3af;
padding: 60px 0;
font-size: 14px;
}
.specialty-card:hover {
border-color: var(--brand-primary);
+3 -2
View File
@@ -56,7 +56,7 @@
<span class="notice-item-no">{{ String((currentPage - 1) * PAGE_SIZE + i + 1).padStart(3, '0') }}</span>
<span class="notice-date">{{ fmtDate(n.date) }}</span>
<span class="notice-title">
<span v-if="n.projectName" style="color:#909399;margin-right:8px;font-size:13px">{{ n.projectName }}</span>
<span v-if="n.projectNo" style="color:#909399;margin-right:8px;font-size:13px">{{ n.projectNo }}</span>
{{ n.title }}
</span>
</div>
@@ -172,8 +172,9 @@ async function load() {
annId: r.projectId,
date: r.publishTime,
type,
title: r.projectName,
projectNo: r.projectNo || '',
projectName: r.projectName,
title: r.projectName,
fileUrl: urlMap[type] || r.invitationUrl || r.supportLetterUrl || r.publishUrl || ''
}
})
+34 -64
View File
@@ -156,28 +156,27 @@
</div>
</footer>
<!-- 分享二维码弹窗 -->
<div class="modal-overlay" :class="showQr ? 'active' : ''" @click.self="showQr = false">
<div class="modal" @click.stop>
<h3 class="modal-title">分享本页</h3>
<div class="modal-qr-wrap">
<span class="qr-corner qr-corner-tl"></span>
<span class="qr-corner qr-corner-tr"></span>
<span class="qr-corner qr-corner-bl"></span>
<span class="qr-corner qr-corner-br"></span>
<div class="modal-qr">
<img v-if="qrUrl" :src="qrUrl" alt="分享二维码" class="qr-img" />
<span v-else class="qr-loading">
<span class="qr-dot"></span><span class="qr-dot"></span><span class="qr-dot"></span>
生成中...
</span>
</div>
<div class="qr-brand">北京整合医学学会</div>
<!-- 分享二维码弹窗 (el-dialog 自带右上角 X 关闭按钮 + Esc + 遮罩点击) -->
<el-dialog v-model="showQr" title="分享本页" width="420px" align-center destroy-on-close>
<div class="modal-qr-wrap">
<span class="qr-corner qr-corner-tl"></span>
<span class="qr-corner qr-corner-tr"></span>
<span class="qr-corner qr-corner-bl"></span>
<span class="qr-corner qr-corner-br"></span>
<div class="modal-qr">
<img v-if="qrUrl" :src="qrUrl" alt="分享二维码" class="qr-img" />
<span v-else class="qr-loading">
<span class="qr-dot"></span><span class="qr-dot"></span><span class="qr-dot"></span>
生成中...
</span>
</div>
<p class="modal-tip">扫码查看{{ ann?.projectName || '项目公示' }}公示详情</p>
<button class="modal-close" @click="showQr = false"> </button>
<div class="qr-brand">北京整合医学学会</div>
</div>
</div>
<p class="modal-tip">扫码查看{{ ann?.projectName || '项目公示' }}公示详情</p>
<template #footer>
<el-button type="primary" :disabled="!qrUrl" @click="onDownloadQr">下载二维码</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -388,6 +387,17 @@ async function onSignup() {
function onKeydown(e) { if (e.key === 'Escape') showQr.value = false }
function handleScroll() { isScrolled.value = window.scrollY > 10 }
// 下载二维码: qrUrl 是 QRCode.toDataURL 生成的 data:image/png;base64,... 直接 <a download> 即可
function onDownloadQr() {
if (!qrUrl.value) return
const a = document.createElement('a')
a.href = qrUrl.value
a.download = `${(ann.value?.projectName || '公示')}_分享二维码.png`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
onMounted(() => {
window.addEventListener('scroll', handleScroll, { passive: true })
window.addEventListener('keydown', onKeydown)
@@ -981,39 +991,11 @@ a { color: inherit; text-decoration: none; }
}
}
/* ========== 二维码弹窗 ========== */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: none;
align-items: center;
justify-content: center;
z-index: 2000;
}
.modal-overlay.active { display: flex; }
.modal {
background: #fff;
padding: 32px 36px;
width: 320px;
text-align: center;
border: 1px solid #e5e7eb;
}
.modal-title {
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin-bottom: 20px;
letter-spacing: 1px;
}
/* ========== 二维码弹窗 (el-dialog 自带标题/X/footer, 这里只写内部 QR 样式) ========== */
.modal-qr-wrap {
position: relative;
width: 220px;
margin: 0 auto 20px;
margin: 0 auto 16px;
padding: 12px 12px 8px;
background: linear-gradient(135deg, #ffffff 0%, #f7faff 100%);
border-radius: 12px;
@@ -1076,22 +1058,10 @@ a { color: inherit; text-decoration: none; }
.modal-tip {
font-size: 12px;
color: #9ca3af;
margin-bottom: 20px;
margin: 0;
text-align: center;
}
.modal-close {
width: 100%;
height: 38px;
background: var(--brand-primary);
color: #fff;
border: none;
font-size: 14px;
cursor: pointer;
letter-spacing: 2px;
}
.modal-close:hover { background: var(--brand-primary-deep); }
/* ========== 底部 ========== */
.footer {
background: var(--brand-primary-darker);
@@ -0,0 +1,341 @@
<template>
<div class="detail-page">
<header class="top-nav" :class="topNavClass">
<a class="logo" title="返回首页" @click.prevent="goHome">
<div class="logo-icon"></div>
<div class="logo-text">
<span class="logo-title">北京整合医学学会</span>
<span class="logo-subtitle">Beijing Association of Holistic Integrative Medicine</span>
</div>
</a>
<ul class="nav-list">
<li class="nav-item"><a class="nav-link" @click.prevent="goHome">年度项目规划</a></li>
<li class="nav-item"><a class="nav-link" @click.prevent="goPublicity">项目公示</a></li>
</ul>
<div class="top-tools">
<template v-if="!loggedIn">
<span class="login-btn" @click="goLogin">登录</span>
</template>
<template v-else>
<el-dropdown trigger="click" @command="onUserCmd">
<a class="user-link" @click.prevent>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
<span>{{ userName }}</span>
</a>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="account">我的主页</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</div>
</header>
<main class="container">
<article class="plan-card" v-loading="loading">
<header class="plan-head">
<h1 class="plan-title">{{ plan.title || '专项计划' }}</h1>
<div class="plan-meta" v-if="plan.updateTime">最后更新: {{ fmt(plan.updateTime) }}</div>
</header>
<!-- 富文本内容 -->
<div v-if="!plan.contentType || plan.contentType === 'rich'"
class="plan-body plan-body-rich"
v-html="plan.content || '<p class=\'empty\'>暂无内容</p>'" />
<!-- 上传文件: PDF / PNG -->
<div v-else class="plan-body plan-body-file">
<iframe v-if="isPdf(plan.fileUrl)"
:src="proxyUrl(plan.fileUrl)"
class="content-frame"
frameborder="0"></iframe>
<img v-else-if="isImg(plan.fileUrl)"
:src="proxyUrl(plan.fileUrl)"
class="content-img"
alt="专项计划文件" />
<div v-else class="content-empty">
<el-icon size="64"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"/></svg></el-icon>
<p>暂无可查看的文件</p>
<p class="hint">请上传 PDF PNG 格式</p>
</div>
</div>
</article>
</main>
<footer class="footer">
<div class="container">
<div class="footer-main">
<div class="footer-brand">
<div class="brand-row">
<div class="footer-logo-icon"></div>
<div>
<div class="footer-brand-name">北京整合医学学会</div>
<div class="footer-brand-en">Beijing Association of Holistic Integrative Medicine</div>
</div>
</div>
<p class="footer-desc">依托"健康中国2030"战略,整合医学资源,推动健康科普与公益事业,助力全民健康素养提升,共建共享健康中国</p>
</div>
<div class="footer-col">
<h4>快速导航</h4>
<a @click.prevent="goHome">首页</a>
<a @click.prevent="goHome">年度项目规划</a>
<a @click.prevent="goPublicity">项目公示</a>
</div>
<div class="footer-col">
<h4>学会项目</h4>
<a>百姓巡常行</a>
<a>专病联盟暨全国专家智库</a>
<a>乡村幸福安康</a>
<a>临床科研资助计划</a>
</div>
<div class="footer-col">
<h4>联系方式</h4>
<p>电话: 010-82089470</p>
<p>邮箱: contactus@bahim.org.cn</p>
<p>地址: 北京市海淀区知春路1号学院国际大厦908-2</p>
<p>邮编: 100083</p>
</div>
<div class="footer-qr">
<div class="qr-image">
<svg width="52" height="52" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 11h8V3H3v8zm2-6h4v4H5V5zm8-2v8h8V3h-8zm6 6h-4V5h4v4zM3 21h8v-8H3v8zm2-6h4v4H5v-4zm13-2h-2v2h2v-2zm-2 2h-2v2h2v-2zm2 2h-2v2h2v-2zm2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm-2-2h-2v2h2v-2zm0 0h-2v2h2v-2z"/>
</svg>
</div>
<div class="qr-label">公众号二维码</div>
</div>
</div>
<div class="footer-bottom">
<span>© 北京整合医学学会 BAHIM</span>
<span>京ICP备2020035479号-1 &nbsp;&nbsp; 京公网安备11010802034820</span>
</div>
</div>
</footer>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { logout as logoutApi } from '@/api/auth'
import request from '@/utils/request'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const isScrolled = ref(false)
const topNavClass = computed(() => isScrolled.value ? 'is-scrolled' : '')
const loggedIn = computed(() => !!userStore.token)
const userName = computed(() => userStore.user?.userName || '用户')
const plan = ref({})
const loading = ref(false)
async function load() {
const id = route.params.id
if (!id) return
loading.value = true
try {
const r = await request({ url: `/business/public/specialPlan/${id}`, method: 'get' })
plan.value = (r.data && r.data.data) || r.data || {}
} catch (e) {
plan.value = {}
} finally { loading.value = false }
}
function fmt(d) {
if (!d) return ''
const date = new Date(d)
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function isPdf(url) {
if (!url) return false
return /\.pdf(\?|$)/i.test(url.split('?')[0])
}
function isImg(url) {
if (!url) return false
return /\.(png|jpg|jpeg|gif|webp)(\?|$)/i.test(url.split('?')[0])
}
// OSS bucket 默认 Content-Disposition: attachment, PDF 会被强制下载.
// 走 /common/oss/proxy 后端代理改写为 inline.
// 参考 PublicityDetail.vue 的 proxyUrl 逻辑
function proxyUrl(url) {
if (!url) return url
if (url.includes('hwtossbamlorgcn.oss-cn-beijing.aliyuncs.com')) {
return '/dev-api/common/oss/proxy?url=' + encodeURIComponent(url) + '#toolbar=0&zoom=page-width'
}
return url
}
function goHome() { router.push('/') }
function goPublicity() { router.push('/publicity') }
function goLogin() { router.push('/login') }
async function goLogout() { try { await logoutApi() } catch {}; userStore.logout(); router.replace('/login') }
async function onUserCmd(cmd) {
if (cmd === 'logout') return goLogout()
if (cmd === 'account') {
const r = (userStore.user?.role || '')
const map = { admin: '/leader/home', leader: '/leader/home', manager: '/manager/workbench', doctor: '/doctor/home', executor: '/executor/meetings', sponsor: '/sponsor/home' }
router.push(map[r] || '/leader/home')
}
}
function handleScroll() { isScrolled.value = window.scrollY > 10 }
onMounted(() => {
window.addEventListener('scroll', handleScroll, { passive: true })
load()
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', handleScroll)
})
watch(() => route.params.id, load)
</script>
<style scoped>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
color: #1f2937;
background: #f5f6f8;
min-width: 1354px;
line-height: 1.6;
}
a { color: inherit; text-decoration: none; }
/* ===== 顶部导航 (与 PublicityDetail 一致) ===== */
.top-nav {
position: sticky; top: 0; z-index: 1000;
height: 72px; padding: 0 60px;
background: var(--brand-primary);
border-bottom: 1px solid rgba(255,255,255,0.08);
display: flex; align-items: center;
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
transition: box-shadow 0.3s;
}
.top-nav.is-scrolled { box-shadow: 0 4px 20px rgba(0,0,0,0.25); }
.logo { display: flex; align-items: center; gap: 12px; }
.logo-icon {
width: 36px; height: 36px;
background: #fff; color: var(--brand-primary);
display: flex; align-items: center; justify-content: center;
font-size: 18px; font-weight: 600;
}
.logo-text { display: flex; flex-direction: column; line-height: 1.2; }
.logo-title { font-size: 16px; font-weight: 600; color: #fff; letter-spacing: 0.5px; }
.logo-subtitle { font-size: 11px; color: rgba(255,255,255,0.6); margin-top: 2px; letter-spacing: 0.3px; }
.nav-list { flex: 1; display: flex; align-items: center; justify-content: center; gap: 36px; list-style: none; }
.nav-item { position: relative; height: 72px; display: flex; align-items: center; }
.nav-link {
font-size: 15px; font-weight: 500; color: rgba(255,255,255,0.85);
cursor: pointer; transition: color 0.25s; position: relative;
height: 72px; display: flex; align-items: center;
}
.nav-link::after {
content: ''; position: absolute; left: 50%; bottom: 0;
width: 0; height: 2px; background: #fff;
transform: translateX(-50%); transition: width 0.3s ease;
}
.nav-item:hover .nav-link, .nav-link.active { color: #fff; }
.nav-item:hover .nav-link::after, .nav-link.active::after { width: 100%; }
.top-tools { display: flex; align-items: center; gap: 16px; }
.login-btn {
padding: 7px 20px; background: #fff; color: var(--brand-primary);
font-size: 13px; font-weight: 500;
cursor: pointer; transition: background 0.2s; letter-spacing: 1px;
}
.login-btn:hover { background: #f3f4f6; }
.user-link { display: inline-flex; align-items: center; gap: 6px; color: #fff; font-size: 14px; cursor: pointer; }
/* ===== 主体 ===== */
.container { max-width: 1354px; margin: 0 auto; padding: 28px 60px 60px; }
.plan-card {
background: #fff;
border: 1px solid #e5e7eb;
box-shadow: 0 2px 12px rgba(0,0,0,0.04);
padding: 56px 80px 64px;
min-height: 480px;
}
.plan-head { text-align: center; border-bottom: 1px solid #eef0f3; padding-bottom: 28px; margin-bottom: 32px; }
.plan-title { font-size: 26px; font-weight: 700; color: #1f2937; letter-spacing: 1px; line-height: 1.4; }
.plan-meta { margin-top: 12px; font-size: 13px; color: #9ca3af; }
/* 富文本 (与 ArticleView 一致) */
.plan-body-rich {
font-size: 15px; line-height: 2; color: #374151;
}
.plan-body-rich :deep(h1),
.plan-body-rich :deep(h2),
.plan-body-rich :deep(h3) { font-weight: 700; color: #1f2937; margin: 24px 0 14px; line-height: 1.5; }
.plan-body-rich :deep(h1) { font-size: 22px; text-align: center; }
.plan-body-rich :deep(h2) { font-size: 18px; }
.plan-body-rich :deep(h3) { font-size: 16px; }
.plan-body-rich :deep(p) { margin: 10px 0; text-indent: 2em; text-align: justify; }
.plan-body-rich :deep(ul),
.plan-body-rich :deep(ol) { margin: 12px 0 12px 2em; }
.plan-body-rich :deep(li) { margin: 6px 0; }
.plan-body-rich :deep(.empty) { text-align: center; color: #c0c4cc; text-indent: 0; padding: 60px 0; }
/* 文件预览 */
.plan-body-file { padding: 0; min-height: 600px; }
.content-frame { width: 100%; height: 700px; display: block; border: 0; }
.content-img { width: 100%; max-width: 100%; display: block; margin: 0 auto; }
.content-empty { padding: 80px 20px; text-align: center; color: #c0c4cc; }
.content-empty p { margin: 8px 0 0; }
.content-empty .hint { font-size: 12px; }
/* ===== 底部 ===== */
.footer { background: var(--brand-primary-darker); color: rgba(255,255,255,0.65); padding: 48px 0 0; }
.footer-main { display: grid; grid-template-columns: 1.4fr 1fr 1fr 1.2fr auto; gap: 48px; padding-bottom: 36px; }
.footer-brand .brand-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.footer-logo-icon {
width: 36px; height: 36px;
background: #fff; color: var(--brand-primary);
display: flex; align-items: center; justify-content: center;
font-size: 18px; font-weight: 600; flex-shrink: 0;
}
.footer-brand-name { font-size: 16px; font-weight: 600; color: #fff; letter-spacing: 1px; }
.footer-brand-en { font-size: 10px; color: rgba(255,255,255,0.4); letter-spacing: 0.5px; margin-top: 2px; }
.footer-desc { font-size: 13px; line-height: 1.9; color: rgba(255,255,255,0.55); }
.footer-col h4 {
font-size: 14px; font-weight: 600; color: #fff;
letter-spacing: 1px; margin-bottom: 16px; padding-bottom: 10px; position: relative;
}
.footer-col h4::after { content: ''; position: absolute; left: 0; bottom: 0; width: 24px; height: 2px; background: #93c5fd; }
.footer-col a, .footer-col p {
display: block; font-size: 13px; color: rgba(255,255,255,0.6);
line-height: 2.1; transition: color 0.2s;
}
.footer-col a { cursor: pointer; }
.footer-col a:hover { color: #fff; }
.footer-qr { text-align: center; }
.qr-image {
width: 100px; height: 100px;
background: #fff;
display: flex; align-items: center; justify-content: center;
color: #1f2937;
}
.qr-label { font-size: 12px; color: rgba(255,255,255,0.5); margin-top: 10px; }
.footer-bottom {
border-top: 1px solid rgba(255,255,255,0.12);
padding: 18px 0;
display: flex; justify-content: space-between;
font-size: 12px; color: rgba(255,255,255,0.4);
}
@media (max-width: 900px) {
.container { padding: 0 24px 40px; }
.plan-card { padding: 32px 24px; }
}
</style>
+26 -21
View File
@@ -112,8 +112,8 @@
<el-form-item label="短信验证码" :required="true">
<div class="sms-row">
<el-input v-model="phoneForm.smsCode" placeholder="请输入验证码" />
<el-button class="btn-sms" :disabled="phoneSmsCountdown > 0" @click="sendSms">
{{ phoneSmsCountdown > 0 ? `${phoneSmsCountdown}s 后重试` : '发送验证码' }}
<el-button class="btn-sms" :disabled="phoneSmsLocked || phoneSmsCountdown > 0" @click="sendSms">
{{ phoneSmsLocked ? '发送中...' : phoneSmsCountdown > 0 ? `${phoneSmsCountdown}s 后重试` : '发送验证码' }}
</el-button>
</div>
</el-form-item>
@@ -134,6 +134,9 @@ import { EditPen } from '@element-plus/icons-vue'
import { useUserStore } from '@/store/user'
import request from '@/utils/request'
import { uploadToOss } from '@/utils/oss'
import { useAsyncLock } from '@/utils/useAsyncLock'
const { locked: phoneSmsLocked, run: sendPhoneSmsOnce } = useAsyncLock()
const store = useUserStore()
const tab = ref('profile')
@@ -350,25 +353,27 @@ async function sendSms() {
if (!/^(1[0-9])\d{9}$/.test(phone)) {
return ElMessage.warning('请输入正确的手机号')
}
try {
const res = await request({
url: '/system/user/profile/sendSmsCode',
method: 'post',
params: { phone }
})
phoneSmsUuid.value = res.uuid || ''
ElMessage.success('验证码已发送')
phoneSmsCountdown.value = 60
phoneSmsTimer = setInterval(() => {
phoneSmsCountdown.value--
if (phoneSmsCountdown.value <= 0) {
clearInterval(phoneSmsTimer)
phoneSmsTimer = null
}
}, 1000)
} catch (e) {
ElMessage.error(e?.msg || '发送失败')
}
await sendPhoneSmsOnce(async () => {
try {
const res = await request({
url: '/system/user/profile/sendSmsCode',
method: 'post',
params: { phone }
})
phoneSmsUuid.value = res.uuid || ''
ElMessage.success('验证码已发送')
phoneSmsCountdown.value = 60
phoneSmsTimer = setInterval(() => {
phoneSmsCountdown.value--
if (phoneSmsCountdown.value <= 0) {
clearInterval(phoneSmsTimer)
phoneSmsTimer = null
}
}, 1000)
} catch (e) {
ElMessage.error(e?.msg || '发送失败')
}
})
}
async function onChangePhone() {
+4 -8
View File
@@ -172,12 +172,8 @@
<el-descriptions-item label="项目编号">{{ detail.projectNo }}</el-descriptions-item>
<el-descriptions-item label="项目形式">{{ detail.projectForm }}</el-descriptions-item>
<el-descriptions-item label="项目名称" :span="2">{{ detail.projectName }}</el-descriptions-item>
<el-descriptions-item label="赞助公司" :span="2">{{ detail.orgName || '-' }}</el-descriptions-item>
<el-descriptions-item label="公司类型" :span="2">
<el-tag :type="detail.orgType === 'sponsor' ? 'success' : 'primary'" disable-transitions>
{{ detail.orgType === 'sponsor' ? '赞助方' : '执行方' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="赞助方负责人" :span="2">{{ detail.sponsorAdminUserName || '-' }}</el-descriptions-item>
<el-descriptions-item label="公司类型" :span="2">赞助方</el-descriptions-item>
<el-descriptions-item label="总场次/总期数">{{ detail.totalSessions || 0 }}</el-descriptions-item>
<el-descriptions-item label="已执行/未执行">{{ detail.doneSessions || 0 }} / {{ detail.todoSessions || 0 }}</el-descriptions-item>
<el-descriptions-item label="总金额">¥{{ formatMoney(detail.totalAmount) }}</el-descriptions-item>
@@ -280,8 +276,8 @@ function onCreateMeeting(row) {
}
function onSupportUnit(row) {
if (!row.orgId) { ElMessage.warning('该项目未关联赞助公司'); return }
ElMessage.info(`查看赞助公司: ${row.orgName || row.orgId}`)
if (!row.sponsorAdminUserId) { ElMessage.warning('该项目未关联赞助方负责人'); return }
ElMessage.info(`查看赞助方负责人: ${row.sponsorAdminUserName || row.sponsorAdminUserId}`)
}
async function onDeleteNotice(row) {
try { await ElMessageBox.confirm(`确定删除「${row.projectName}」的所有公告吗?删除后无法恢复`, '删除公告', { type: 'warning' }) } catch { return }