提交 e0e5fd07 authored 作者: 王鹏飞's avatar 王鹏飞

feat: 新增bbs

上级 4e345fb5
......@@ -8,18 +8,18 @@
"name": "saas-learn",
"version": "0.0.0",
"dependencies": {
"@element-plus/icons-vue": "^2.0.6",
"@element-plus/icons-vue": "^2.0.10",
"@tinymce/tinymce-vue": "^5.0.0",
"@types/ua-parser-js": "^0.7.36",
"@vueuse/core": "^9.0.2",
"axios": "^0.27.2",
"blueimp-md5": "^2.19.0",
"dayjs": "^1.11.4",
"element-plus": "^2.2.12",
"element-plus": "^2.2.18",
"file-saver": "^2.0.5",
"format-duration": "^2.0.0",
"lodash-es": "^4.17.21",
"pinia": "^2.0.17",
"pinia": "^2.0.23",
"qs": "^6.11.0",
"swiper": "^8.3.2",
"ua-parser-js": "^1.0.2",
......@@ -87,9 +87,9 @@
}
},
"node_modules/@element-plus/icons-vue": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.0.6.tgz",
"integrity": "sha512-lPpG8hYkjL/Z97DH5Ei6w6o22Z4YdNglWCNYOPcB33JCF2A4wye6HFgSI7hEt9zdLyxlSpiqtgf9XcYU+m5mew==",
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.0.10.tgz",
"integrity": "sha512-ygEZ1mwPjcPo/OulhzLE7mtDrQBWI8vZzEWSNB2W/RNCRjoQGwbaK4N8lV4rid7Ts4qvySU3njMN7YCiSlSaTQ==",
"peerDependencies": {
"vue": "^3.2.0"
}
......@@ -115,16 +115,16 @@
}
},
"node_modules/@floating-ui/core": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-0.7.3.tgz",
"integrity": "sha512-buc8BXHmG9l82+OQXOFU3Kr2XQx9ys01U/Q9HMIrZ300iLc8HLMgh7dcCqgYzAzf4BkoQvDcXf5Y+CuEZ5JBYg=="
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.0.1.tgz",
"integrity": "sha512-bO37brCPfteXQfFY0DyNDGB3+IMe4j150KFQcgJ5aBP295p9nBGeHEs/p0czrRbtlHq4Px/yoPXO/+dOCcF4uA=="
},
"node_modules/@floating-ui/dom": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-0.5.4.tgz",
"integrity": "sha512-419BMceRLq0RrmTSDxn8hf9R3VCJv2K9PUfugh5JyEFmdjzDo+e8U5EdR8nzKq8Yj1htzLm3b6eQEEam3/rrtg==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.0.3.tgz",
"integrity": "sha512-6H1kwjkOZKabApNtXRiYHvMmYJToJ1DV7rQ3xc/WJpOABhQIOJJOdz2AOejj8X+gcybaFmBpisVTZxBZAM3V0w==",
"dependencies": {
"@floating-ui/core": "^0.7.3"
"@floating-ui/core": "^1.0.1"
}
},
"node_modules/@humanwhocodes/config-array": {
......@@ -286,9 +286,9 @@
"dev": true
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.15",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.15.tgz",
"integrity": "sha512-w7hEHXnPMEZ+4nGKl/KDRVpxkwYxYExuHOYXyzIzCDzEZ9ZCGMAewulr9IqJu2LR4N37fcnb1XVeuZ09qgOxhA=="
"version": "0.0.16",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz",
"integrity": "sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ=="
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "5.30.6",
......@@ -641,9 +641,9 @@
}
},
"node_modules/@vue/devtools-api": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.2.1.tgz",
"integrity": "sha512-OEgAMeQXvCoJ+1x8WyQuVZzFo0wcyCmUR3baRVLmKBo1LmYZWMlRiXlux5jd0fqVJu6PfDbOrZItVqUEzLobeQ=="
"version": "6.4.5",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.4.5.tgz",
"integrity": "sha512-JD5fcdIuFxU4fQyXUu3w2KpAJHzTVdN+p4iOX2lMWSHMOoQdMAcpFLZzm9Z/2nmsoZ1a96QEhZ26e50xLBsgOQ=="
},
"node_modules/@vue/eslint-config-typescript": {
"version": "11.0.0",
......@@ -740,13 +740,13 @@
}
},
"node_modules/@vueuse/core": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-9.0.2.tgz",
"integrity": "sha512-kOIqaQPSs7OSByWg1ulEKRUJbsq3FmbJiUr0RhEKpt3O1Uhl4DrDj85DUbQBABVYgPvSaY6AE/fP3/FOcRIOoQ==",
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-9.3.1.tgz",
"integrity": "sha512-xriyD+v3D2ObH/UtnkEl+1sbcLBVHNaZaLi/rqoNEe/B92hggDEFQIGXoQUjdRzYOjASHSezf9uCDtmd7LeWyA==",
"dependencies": {
"@types/web-bluetooth": "^0.0.15",
"@vueuse/metadata": "9.0.2",
"@vueuse/shared": "9.0.2",
"@types/web-bluetooth": "^0.0.16",
"@vueuse/metadata": "9.3.1",
"@vueuse/shared": "9.3.1",
"vue-demi": "*"
},
"funding": {
......@@ -779,17 +779,17 @@
}
},
"node_modules/@vueuse/metadata": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-9.0.2.tgz",
"integrity": "sha512-TRh+TNUYXiodatSAxd0xZc7sh4RfktVVgNFIN7TCQXKyancbCAcWfHvKfgdlX8LcqSBxKoHVa90n0XdUbboTkw==",
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-9.3.1.tgz",
"integrity": "sha512-G1BPhtx3OHaL/y4OZBofh6Xt02G1VA9PuOO8nac9sTKMkMqfyez5VfkF3D9GUjSRNO7cVWyH4rceeGXfr2wdMg==",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-9.0.2.tgz",
"integrity": "sha512-KwBDefK2ljLESpt0ffe2w8EGUCb3IaMfTzeytB/uHHjHOGOEIHLHHyn8W2C48uGQEvoe5iwaW4Bfp8cRUM6IFA==",
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-9.3.1.tgz",
"integrity": "sha512-YFu3qcnVeu0S2L4XdQJtBpDcjz6xwqHZtTv/XRhu66/yge1XVhxskUcc7VZbX52xF9A34V6KCfwncP9YDqYFiw==",
"dependencies": {
"vue-demi": "*"
},
......@@ -798,9 +798,9 @@
}
},
"node_modules/@vueuse/shared/node_modules/vue-demi": {
"version": "0.13.6",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.6.tgz",
"integrity": "sha512-02NYpxgyGE2kKGegRPYlNQSL1UWfA/+JqvzhGCOYjhfbLWXU5QQX0+9pAm/R2sCOPKr5NBxVIab7fvFU0B1RxQ==",
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz",
"integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==",
"hasInstallScript": true,
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
......@@ -1465,17 +1465,17 @@
"dev": true
},
"node_modules/element-plus": {
"version": "2.2.12",
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.2.12.tgz",
"integrity": "sha512-g/hIHj3b+dND2R3YRvyvCJtJhQvR7lWvXqhJaoxaQmajjNWedoe4rttxG26fOSv9YCC2wN4iFDcJHs70YFNgrA==",
"version": "2.2.18",
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.2.18.tgz",
"integrity": "sha512-2pK2zmVOwP14eFl3rGoR+3BWJwDyO+DZCvzjQ8L6qjUR+hVKwFhgxIcSkKJatbcHFw5Xui6UyN20jV+gQP7mLg==",
"dependencies": {
"@ctrl/tinycolor": "^3.4.1",
"@element-plus/icons-vue": "^2.0.6",
"@floating-ui/dom": "^0.5.4",
"@floating-ui/dom": "^1.0.1",
"@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7",
"@types/lodash": "^4.14.182",
"@types/lodash-es": "^4.17.6",
"@vueuse/core": "^8.7.5",
"@vueuse/core": "^9.1.0",
"async-validator": "^4.2.5",
"dayjs": "^1.11.3",
"escape-html": "^1.0.3",
......@@ -1489,93 +1489,6 @@
"vue": "^3.2.0"
}
},
"node_modules/element-plus/node_modules/@types/web-bluetooth": {
"version": "0.0.14",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.14.tgz",
"integrity": "sha512-5d2RhCard1nQUC3aHcq/gHzWYO6K0WJmAbjO7mQJgCQKtZpgXxv1rOM6O/dBDhDYYVutk1sciOgNSe+5YyfM8A=="
},
"node_modules/element-plus/node_modules/@vueuse/core": {
"version": "8.9.4",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-8.9.4.tgz",
"integrity": "sha512-B/Mdj9TK1peFyWaPof+Zf/mP9XuGAngaJZBwPaXBvU3aCTZlx3ltlrFFFyMV4iGBwsjSCeUCgZrtkEj9dS2Y3Q==",
"dependencies": {
"@types/web-bluetooth": "^0.0.14",
"@vueuse/metadata": "8.9.4",
"@vueuse/shared": "8.9.4",
"vue-demi": "*"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.1.0",
"vue": "^2.6.0 || ^3.2.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/element-plus/node_modules/@vueuse/core/node_modules/@vueuse/shared": {
"version": "8.9.4",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-8.9.4.tgz",
"integrity": "sha512-wt+T30c4K6dGRMVqPddexEVLa28YwxW5OFIPmzUHICjphfAuBFTTdDoyqREZNDOFJZ44ARH1WWQNCUK8koJ+Ag==",
"dependencies": {
"vue-demi": "*"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.1.0",
"vue": "^2.6.0 || ^3.2.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/element-plus/node_modules/@vueuse/core/node_modules/vue-demi": {
"version": "0.13.5",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.5.tgz",
"integrity": "sha512-tO3K2bML3AwiHmVHeKCq6HLef2st4zBXIV5aEkoJl6HZ+gJWxWv2O8wLH8qrA3SX3lDoTDHNghLX1xZg83MXvw==",
"hasInstallScript": true,
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/element-plus/node_modules/@vueuse/metadata": {
"version": "8.9.4",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-8.9.4.tgz",
"integrity": "sha512-IwSfzH80bnJMzqhaapqJl9JRIiyQU0zsRGEgnxN6jhq7992cPUJIRfV+JHRIZXjYqbwt07E1gTEp0R0zPJ1aqw==",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/end-of-stream": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
......@@ -3492,11 +3405,11 @@
}
},
"node_modules/pinia": {
"version": "2.0.17",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-2.0.17.tgz",
"integrity": "sha512-AtwLwEWQgIjofjgeFT+nxbnK5lT2QwQjaHNEDqpsi2AiCwf/NY78uWTeHUyEhiiJy8+sBmw0ujgQMoQbWiZDfA==",
"version": "2.0.23",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-2.0.23.tgz",
"integrity": "sha512-N15hFf4o5STrxpNrib1IEb1GOArvPYf1zPvQVRGOO1G1d74Ak0J0lVyalX/SmrzdT4Q0nlEFjbURsmBmIGUR5Q==",
"dependencies": {
"@vue/devtools-api": "^6.2.1",
"@vue/devtools-api": "^6.4.4",
"vue-demi": "*"
},
"funding": {
......@@ -4880,9 +4793,9 @@
"integrity": "sha512-ej5oVy6lykXsvieQtqZxCOaLT+xD4+QNarq78cIYISHmZXshCvROLudpQN3lfL8G0NL7plMSSK+zlyvCaIJ4Iw=="
},
"@element-plus/icons-vue": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.0.6.tgz",
"integrity": "sha512-lPpG8hYkjL/Z97DH5Ei6w6o22Z4YdNglWCNYOPcB33JCF2A4wye6HFgSI7hEt9zdLyxlSpiqtgf9XcYU+m5mew==",
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.0.10.tgz",
"integrity": "sha512-ygEZ1mwPjcPo/OulhzLE7mtDrQBWI8vZzEWSNB2W/RNCRjoQGwbaK4N8lV4rid7Ts4qvySU3njMN7YCiSlSaTQ==",
"requires": {}
},
"@eslint/eslintrc": {
......@@ -4903,16 +4816,16 @@
}
},
"@floating-ui/core": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-0.7.3.tgz",
"integrity": "sha512-buc8BXHmG9l82+OQXOFU3Kr2XQx9ys01U/Q9HMIrZ300iLc8HLMgh7dcCqgYzAzf4BkoQvDcXf5Y+CuEZ5JBYg=="
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.0.1.tgz",
"integrity": "sha512-bO37brCPfteXQfFY0DyNDGB3+IMe4j150KFQcgJ5aBP295p9nBGeHEs/p0czrRbtlHq4Px/yoPXO/+dOCcF4uA=="
},
"@floating-ui/dom": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-0.5.4.tgz",
"integrity": "sha512-419BMceRLq0RrmTSDxn8hf9R3VCJv2K9PUfugh5JyEFmdjzDo+e8U5EdR8nzKq8Yj1htzLm3b6eQEEam3/rrtg==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.0.3.tgz",
"integrity": "sha512-6H1kwjkOZKabApNtXRiYHvMmYJToJ1DV7rQ3xc/WJpOABhQIOJJOdz2AOejj8X+gcybaFmBpisVTZxBZAM3V0w==",
"requires": {
"@floating-ui/core": "^0.7.3"
"@floating-ui/core": "^1.0.1"
}
},
"@humanwhocodes/config-array": {
......@@ -5048,9 +4961,9 @@
"dev": true
},
"@types/web-bluetooth": {
"version": "0.0.15",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.15.tgz",
"integrity": "sha512-w7hEHXnPMEZ+4nGKl/KDRVpxkwYxYExuHOYXyzIzCDzEZ9ZCGMAewulr9IqJu2LR4N37fcnb1XVeuZ09qgOxhA=="
"version": "0.0.16",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz",
"integrity": "sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ=="
},
"@typescript-eslint/eslint-plugin": {
"version": "5.30.6",
......@@ -5297,9 +5210,9 @@
}
},
"@vue/devtools-api": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.2.1.tgz",
"integrity": "sha512-OEgAMeQXvCoJ+1x8WyQuVZzFo0wcyCmUR3baRVLmKBo1LmYZWMlRiXlux5jd0fqVJu6PfDbOrZItVqUEzLobeQ=="
"version": "6.4.5",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.4.5.tgz",
"integrity": "sha512-JD5fcdIuFxU4fQyXUu3w2KpAJHzTVdN+p4iOX2lMWSHMOoQdMAcpFLZzm9Z/2nmsoZ1a96QEhZ26e50xLBsgOQ=="
},
"@vue/eslint-config-typescript": {
"version": "11.0.0",
......@@ -5373,13 +5286,13 @@
"requires": {}
},
"@vueuse/core": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-9.0.2.tgz",
"integrity": "sha512-kOIqaQPSs7OSByWg1ulEKRUJbsq3FmbJiUr0RhEKpt3O1Uhl4DrDj85DUbQBABVYgPvSaY6AE/fP3/FOcRIOoQ==",
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-9.3.1.tgz",
"integrity": "sha512-xriyD+v3D2ObH/UtnkEl+1sbcLBVHNaZaLi/rqoNEe/B92hggDEFQIGXoQUjdRzYOjASHSezf9uCDtmd7LeWyA==",
"requires": {
"@types/web-bluetooth": "^0.0.15",
"@vueuse/metadata": "9.0.2",
"@vueuse/shared": "9.0.2",
"@types/web-bluetooth": "^0.0.16",
"@vueuse/metadata": "9.3.1",
"@vueuse/shared": "9.3.1",
"vue-demi": "*"
},
"dependencies": {
......@@ -5392,22 +5305,22 @@
}
},
"@vueuse/metadata": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-9.0.2.tgz",
"integrity": "sha512-TRh+TNUYXiodatSAxd0xZc7sh4RfktVVgNFIN7TCQXKyancbCAcWfHvKfgdlX8LcqSBxKoHVa90n0XdUbboTkw=="
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-9.3.1.tgz",
"integrity": "sha512-G1BPhtx3OHaL/y4OZBofh6Xt02G1VA9PuOO8nac9sTKMkMqfyez5VfkF3D9GUjSRNO7cVWyH4rceeGXfr2wdMg=="
},
"@vueuse/shared": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-9.0.2.tgz",
"integrity": "sha512-KwBDefK2ljLESpt0ffe2w8EGUCb3IaMfTzeytB/uHHjHOGOEIHLHHyn8W2C48uGQEvoe5iwaW4Bfp8cRUM6IFA==",
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-9.3.1.tgz",
"integrity": "sha512-YFu3qcnVeu0S2L4XdQJtBpDcjz6xwqHZtTv/XRhu66/yge1XVhxskUcc7VZbX52xF9A34V6KCfwncP9YDqYFiw==",
"requires": {
"vue-demi": "*"
},
"dependencies": {
"vue-demi": {
"version": "0.13.6",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.6.tgz",
"integrity": "sha512-02NYpxgyGE2kKGegRPYlNQSL1UWfA/+JqvzhGCOYjhfbLWXU5QQX0+9pAm/R2sCOPKr5NBxVIab7fvFU0B1RxQ==",
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz",
"integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==",
"requires": {}
}
}
......@@ -5920,17 +5833,17 @@
"dev": true
},
"element-plus": {
"version": "2.2.12",
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.2.12.tgz",
"integrity": "sha512-g/hIHj3b+dND2R3YRvyvCJtJhQvR7lWvXqhJaoxaQmajjNWedoe4rttxG26fOSv9YCC2wN4iFDcJHs70YFNgrA==",
"version": "2.2.18",
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.2.18.tgz",
"integrity": "sha512-2pK2zmVOwP14eFl3rGoR+3BWJwDyO+DZCvzjQ8L6qjUR+hVKwFhgxIcSkKJatbcHFw5Xui6UyN20jV+gQP7mLg==",
"requires": {
"@ctrl/tinycolor": "^3.4.1",
"@element-plus/icons-vue": "^2.0.6",
"@floating-ui/dom": "^0.5.4",
"@floating-ui/dom": "^1.0.1",
"@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7",
"@types/lodash": "^4.14.182",
"@types/lodash-es": "^4.17.6",
"@vueuse/core": "^8.7.5",
"@vueuse/core": "^9.1.0",
"async-validator": "^4.2.5",
"dayjs": "^1.11.3",
"escape-html": "^1.0.3",
......@@ -5939,45 +5852,6 @@
"lodash-unified": "^1.0.2",
"memoize-one": "^6.0.0",
"normalize-wheel-es": "^1.2.0"
},
"dependencies": {
"@types/web-bluetooth": {
"version": "0.0.14",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.14.tgz",
"integrity": "sha512-5d2RhCard1nQUC3aHcq/gHzWYO6K0WJmAbjO7mQJgCQKtZpgXxv1rOM6O/dBDhDYYVutk1sciOgNSe+5YyfM8A=="
},
"@vueuse/core": {
"version": "8.9.4",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-8.9.4.tgz",
"integrity": "sha512-B/Mdj9TK1peFyWaPof+Zf/mP9XuGAngaJZBwPaXBvU3aCTZlx3ltlrFFFyMV4iGBwsjSCeUCgZrtkEj9dS2Y3Q==",
"requires": {
"@types/web-bluetooth": "^0.0.14",
"@vueuse/metadata": "8.9.4",
"@vueuse/shared": "8.9.4",
"vue-demi": "*"
},
"dependencies": {
"@vueuse/shared": {
"version": "8.9.4",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-8.9.4.tgz",
"integrity": "sha512-wt+T30c4K6dGRMVqPddexEVLa28YwxW5OFIPmzUHICjphfAuBFTTdDoyqREZNDOFJZ44ARH1WWQNCUK8koJ+Ag==",
"requires": {
"vue-demi": "*"
}
},
"vue-demi": {
"version": "0.13.5",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.5.tgz",
"integrity": "sha512-tO3K2bML3AwiHmVHeKCq6HLef2st4zBXIV5aEkoJl6HZ+gJWxWv2O8wLH8qrA3SX3lDoTDHNghLX1xZg83MXvw==",
"requires": {}
}
}
},
"@vueuse/metadata": {
"version": "8.9.4",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-8.9.4.tgz",
"integrity": "sha512-IwSfzH80bnJMzqhaapqJl9JRIiyQU0zsRGEgnxN6jhq7992cPUJIRfV+JHRIZXjYqbwt07E1gTEp0R0zPJ1aqw=="
}
}
},
"end-of-stream": {
......@@ -7376,11 +7250,11 @@
"dev": true
},
"pinia": {
"version": "2.0.17",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-2.0.17.tgz",
"integrity": "sha512-AtwLwEWQgIjofjgeFT+nxbnK5lT2QwQjaHNEDqpsi2AiCwf/NY78uWTeHUyEhiiJy8+sBmw0ujgQMoQbWiZDfA==",
"version": "2.0.23",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-2.0.23.tgz",
"integrity": "sha512-N15hFf4o5STrxpNrib1IEb1GOArvPYf1zPvQVRGOO1G1d74Ak0J0lVyalX/SmrzdT4Q0nlEFjbURsmBmIGUR5Q==",
"requires": {
"@vue/devtools-api": "^6.2.1",
"@vue/devtools-api": "^6.4.4",
"vue-demi": "*"
},
"dependencies": {
......
......@@ -14,18 +14,18 @@
"cert": "node ./cert.js"
},
"dependencies": {
"@element-plus/icons-vue": "^2.0.6",
"@element-plus/icons-vue": "^2.0.10",
"@tinymce/tinymce-vue": "^5.0.0",
"@types/ua-parser-js": "^0.7.36",
"@vueuse/core": "^9.0.2",
"axios": "^0.27.2",
"blueimp-md5": "^2.19.0",
"dayjs": "^1.11.4",
"element-plus": "^2.2.12",
"element-plus": "^2.2.18",
"file-saver": "^2.0.5",
"format-duration": "^2.0.0",
"lodash-es": "^4.17.21",
"pinia": "^2.0.17",
"pinia": "^2.0.23",
"qs": "^6.11.0",
"swiper": "^8.3.2",
"ua-parser-js": "^1.0.2",
......
......@@ -75,3 +75,13 @@ export function collectionResource(data: {
}) {
return httpRequest.post('/api/learn/api/v1/collection/resource', data)
}
// 获取所有课程列表
export function getCourseList() {
return httpRequest.get('/api/learn/api/v1/course/all')
}
// 获取课程章节列表
export function getCourseChapterList(params: { course_id: string }) {
return httpRequest.get('/api/learn/api/v1/chapter/all', { params })
}
......@@ -97,3 +97,7 @@ textarea:focus {
.el-tabs__nav-wrap::after {
height: 1px !important;
}
body {
font-size: 14px;
}
......@@ -8,26 +8,24 @@ interface IRemoteProps {
callback?: any
}
const props = withDefaults(
defineProps<{
remote?: IRemoteProps
filters?: any[]
moreFilters?: any[]
columns?: any[]
data?: any[]
hasPagination?: boolean
limit?: number
isLimit?: boolean
}>(),
{
isLimit: false,
hasPagination: true,
limit: 10,
data() {
return []
}
}
)
interface Props {
remote?: IRemoteProps
filters?: any[]
filterForm?: any
columns?: any[]
data?: any[]
hasPagination?: boolean
limit?: number
hasFilterButton?: boolean
}
const props = withDefaults(defineProps<Props>(), {
hasPagination: true,
hasFilterButton: true,
limit: 10,
filters: () => [],
columns: () => [],
data: () => []
})
const filterFormRef = ref()
const loading = ref(false)
......@@ -36,13 +34,12 @@ const dataList = ref<any[]>([])
const page = reactive({ total: 0, size: props.limit, currentPage: 1 })
const params = reactive({ ...props.remote?.params })
watch(
() => props.data,
list => {
dataList.value = list || []
},
{ immediate: true }
)
watchEffect(() => {
Object.assign(params, props.remote?.params)
})
watchEffect(() => {
dataList.value = props.data || []
})
// 获取数据
const fetchList = (isReset = false) => {
......@@ -60,19 +57,15 @@ const fetchList = (isReset = false) => {
// 翻页参数设置
if (props.hasPagination) {
requestParams.page = page.currentPage
if (props.isLimit === true) {
requestParams.limit = page.size
} else {
requestParams['per-page'] = page.size
}
requestParams.limit = page.size
}
// 接口请求之前
if (beforeRequest) {
requestParams = beforeRequest(requestParams, isReset)
}
for (const key in params) {
if (params[key] === '' || params[key] === undefined || params[key] === undefined) {
delete params[key]
for (const key in requestParams) {
if (requestParams[key] === '' || requestParams[key] === undefined || requestParams[key] === undefined) {
delete requestParams[key]
}
}
loading.value = true
......@@ -131,9 +124,10 @@ defineExpose({ refetch, tableRef })
<template>
<div class="table-list">
<div class="table-list-hd">
<slot name="header-prepend" />
<!-- 筛选 -->
<div class="table-list-filter" v-if="filters && filters.length">
<el-form :inline="true" :model="params" ref="filterFormRef" @submit.prevent>
<el-form :inline="true" :model="params" v-bind="filterForm" ref="filterFormRef" @submit.prevent>
<template v-for="item in filters" :key="item.prop">
<el-form-item :label="item.label" :prop="item.prop">
<template v-if="item.slots">
......@@ -147,47 +141,44 @@ defineExpose({ refetch, tableRef })
clearable
@change="search"
style="width: 200px"
v-if="item.type === 'input'"
/>
v-if="item.type === 'input'" />
<!-- select -->
<el-select
v-model="params[item.prop]"
v-bind="item"
clearable
filterable
@change="search"
v-if="item.type === 'select'"
>
v-if="item.type === 'select'">
<el-option
:label="option[item.labelKey] || option.label"
:value="option[item.valueKey] || option.value"
v-for="(option, index) in item.options"
:key="index"
/>
:key="index" />
</el-select>
</template>
</el-form-item>
</template>
<el-form-item class="filter-buttons">
<el-form-item class="filter-buttons" v-if="hasFilterButton">
<el-button type="primary" :icon="Search" @click="search">搜索</el-button>
<el-button :icon="RefreshLeft" @click="reset">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="table-list-hd-aside"><slot name="header-aside" /></div>
<slot name="header-append" />
</div>
<div class="table-list-buttons"><slot name="header-buttons"></slot></div>
<slot></slot>
<!-- 主体 -->
<div class="table-list-bd">
<slot name="body" v-bind="{ data: dataList }">
<el-table
stripe
:header-cell-style="{ background: '#ededed' }"
:data="dataList"
v-loading="loading"
v-bind="$attrs"
style="height: 100%"
ref="tableRef"
:header-cell-style="{ background: '#EFEFEF' }"
>
<el-table-column v-bind="item || {}" v-for="item in columns" :key="item.prop">
ref="tableRef">
<el-table-column align="center" v-bind="item || {}" v-for="item in columns" :key="item.prop">
<template #default="scope" v-if="item.slots || item.computed">
<slot :name="item.slots" v-bind="scope" v-if="item.slots"></slot>
<div v-html="item.computed(scope)" v-if="item.computed"></div>
......@@ -212,8 +203,7 @@ defineExpose({ refetch, tableRef })
@size-change="pageSizeChange"
@current-change="fetchList()"
:hide-on-single-page="true"
v-if="hasPagination"
>
v-if="hasPagination">
</el-pagination>
</div>
</div>
......@@ -229,10 +219,22 @@ defineExpose({ refetch, tableRef })
.table-list-hd {
display: flex;
margin-bottom: 10px;
margin-bottom: 20px;
&:empty {
display: none;
}
}
.table-list-filter {
flex: 1;
// padding: 30px 30px 10px;
// background: #f8f8f8;
// border-radius: 12px;
}
.table-list-buttons {
margin-bottom: 20px;
&:empty {
display: none;
}
}
// .table-list-bd {
// flex: 1;
......@@ -249,4 +251,8 @@ defineExpose({ refetch, tableRef })
.el-table-column--selection .cell {
padding: 0 14px !important;
}
.el-button a {
margin: -8px -15px;
padding: 8px 15px;
}
</style>
import { getCourseList, getCourseChapterList } from '@/api/base'
import { val } from 'dom7'
interface Semester {
id: string
name: string
}
interface Course {
id: string
course_id: string
name: string
cover: string
course_alias_name: string
semester: Semester
}
interface Chapter {
id: string
name: string
}
export function useGetCourseList() {
const courseId = ref('')
const courses = ref<Course[]>([])
const chapters = ref<Chapter[]>([])
// 获取课程列表
function getCourses() {
getCourseList().then(res => {
courses.value = res.data.items
})
}
// 获取章节列表
function getChapters() {
if (!courseId.value) {
chapters.value = []
return
}
getCourseChapterList({ course_id: courseId.value }).then(res => {
chapters.value = res.data.items
})
}
getCourses()
watch(courseId, () => {
getChapters()
})
return { courses, courseId, chapters }
}
import httpRequest from '@/utils/axios'
// 获取帖子列表
export function getPostList(params: {
search_by: number
order_by: number
course_id?: string
semester_id?: string
chapter_id?: string
type?: string
page?: number
limit?: number
}) {
return httpRequest.get('/api/learn/api/v1/discussions', { params })
}
// 获取置顶帖子列表
export function getTopPostList(params?: { course_id?: string; semester_id?: string }) {
return httpRequest.get('/api/learn/api/v1/discussion/top-list', { params })
}
// 获取帖子详情
export function getPostAndDiscussList(params: { id: string }) {
return httpRequest.get(`/api/learn/api/v1/discussion/${params.id}/detail-list`, { params })
}
// 获取回复列表
export function getCommitList(params: { floor_id: string; page?: number; limit?: number }) {
return httpRequest.get(`/api/learn/api/v1/discussion/reply-list/${params.floor_id}`, { params })
}
// 创建帖子
export function createPost(data: {
course_id: string
semester_id: string
chapter_id: string
type: string
title: string
content: string
files: string
}) {
return httpRequest.post('/api/learn/api/v1/discussion/post', data, {
headers: { 'Content-Type': 'application/json' }
})
}
// 回复帖子
export function replyToPost(data: {
id: string
reply_type: number
content: string
files?: string
floor_id?: string
reply_id?: string
}) {
return httpRequest.post(`/api/learn/api/v1/discussion/${data.id}/reply`, data, {
headers: { 'Content-Type': 'application/json' }
})
}
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage } from 'element-plus'
import AppUpload from '@/components/base/AppUpload.vue'
import AppEditor from '@/components/base/AppEditor.vue'
import { replyToPost } from '../api'
interface Props {
id: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'update'): void
(e: 'update:modelValue', visible: boolean): void
}>()
const formRef = $ref<FormInstance>()
const form = reactive({
id: props.id,
reply_type: 1,
content: '',
files: []
})
const rules = ref<FormRules>({
content: [{ required: true, message: '请输入回复内容', trigger: 'blur' }]
})
// 提交
function handleSubmit() {
formRef?.validate().then(create)
}
// 修改
const create = () => {
const params = Object.assign({}, form, { files: form.files.length ? JSON.stringify(form.files) : '' })
replyToPost(params).then(() => {
ElMessage({ message: '发布成功', type: 'success' })
emit('update')
emit('update:modelValue', false)
})
}
</script>
<template>
<el-dialog title="发表回复" width="800px" @update:modelValue="$emit('update:modelValue')">
<el-form ref="formRef" :model="form" :rules="rules" hide-required-asterisk label-position="top">
<el-form-item prop="content">
<AppEditor v-model="form.content" :height="300" />
</el-form-item>
<el-form-item prop="files">
<AppUpload v-model="form.files">
<el-button size="default">上传图片/视频附件</el-button>
<template #tip
>支持最多上传10张图片,格式支持jpg,jpeg,png,2MB以内<br />视频最多上传1个,100Mb以内
</template>
</AppUpload>
</el-form-item>
<el-form-item>
<el-button type="primary" auto-insert-space @click="handleSubmit">发表</el-button>
</el-form-item>
</el-form>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import type { DiscussItem } from '../types'
import { ElMessage } from 'element-plus'
import DiscussItemCommentList from './DiscussItemCommentList.vue'
import FileItem from './FileItem.vue'
import { replyToPost } from '../api'
interface Props {
data: DiscussItem
}
const props = defineProps<Props>()
const username = $computed(() => {
const user = props.data.sso_user
return user.realname || user.nickname || user.username
})
const formVisible = $ref(false)
const formRef = $ref<FormInstance>()
const form = reactive({
content: '',
files: []
})
const rules = ref<FormRules>({
content: [{ required: true, message: '请输入回复内容', trigger: 'blur' }]
})
// 发布回复
function handleSubmit() {
formRef?.validate().then(() => {
replyToPost({
id: props.data.discussion_id,
reply_type: 2,
floor_id: props.data.id,
reply_id: props.data.id,
content: form.content
}).then(() => {
ElMessage({ message: '回复成功', type: 'success' })
})
})
}
</script>
<template>
<section class="discuss-item">
<div class="discuss-item__left">
<div class="discuss-item__avatar">
<img :src="data.sso_user.avatar || 'https://webapp-pub.ezijing.com/website/base/images/default.jpg'" />
</div>
<p class="discuss-item__username">{{ username }}</p>
</div>
<div class="discuss-item__right">
<div class="discuss-item__main">
<div class="discuss-item__content" v-html="data.content"></div>
<ul class="discuss-item__files" v-if="data.files.length">
<li v-for="(item, index) in data.files" :key="index"><FileItem :data="item" /></li>
</ul>
</div>
<p class="discuss-item__time">{{ data.updated_time }}</p>
<div class="discuss-item__comment"><p @click="formVisible = !formVisible">回复</p></div>
<div class="discuss-item-form" v-if="formVisible">
<p class="discuss-item-form__title">回复本楼</p>
<el-form ref="formRef" :model="form" :rules="rules" hide-required-asterisk>
<el-form-item prop="content">
<el-input type="textarea" :autosize="{ minRows: 6, maxRows: 6 }" v-model="form.content" />
</el-form-item>
<el-row justify="end">
<el-button round type="primary" @click="handleSubmit">发表回复</el-button>
</el-row>
</el-form>
</div>
<DiscussItemCommentList :data="data" />
</div>
</section>
</template>
<style lang="scss">
.discuss-item {
display: flex;
border-bottom: 4px solid #e8e8e8;
}
.discuss-item__left {
width: 240px;
padding: 40px;
text-align: center;
background-color: #f9f9fc;
box-sizing: border-box;
}
.discuss-item__avatar {
width: 100px;
height: 100px;
margin: 0 auto;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.discuss-item__username {
margin-top: 14px;
font-size: 16px;
color: #333;
}
.discuss-item__right {
flex: 1;
overflow: hidden;
padding: 40px;
> .discuss-item__comment {
margin-right: 20px;
}
}
.discuss-item__main {
min-height: 180px;
}
.discuss-item__content {
font-size: 16px;
color: #666;
}
.discuss-item__time {
margin-top: 20px;
font-size: 14px;
line-height: 1;
color: #b4b4b4;
}
.discuss-item__files {
margin-top: 20px;
display: flex;
}
.discuss-item__comment {
text-align: right;
p {
display: inline-block;
padding-left: 32px;
background: url(@/assets/images/icon_comment.png) no-repeat left center;
background-size: 22px;
font-size: 16px;
line-height: 28px;
color: #9b9b9b;
cursor: pointer;
}
}
.discuss-item-form__title {
font-size: 18px;
color: #333;
line-height: 40px;
}
</style>
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import type { DiscussItem, DiscussCommentItem, User } from '../types'
import { ElMessage } from 'element-plus'
import { getCommitList, replyToPost } from '../api'
interface Props {
data: DiscussItem
}
const props = defineProps<Props>()
let page = $ref(1)
let list = $ref<DiscussCommentItem[]>([])
function fetchList() {
getCommitList({ floor_id: props.data.id, page }).then(res => {
list = page === 1 ? res.data.data : list.concat(res.data.data)
page++
})
}
function refetch() {
page = 1
fetchList()
}
// 加载更多
function loadMore() {
fetchList()
}
const currentList = $computed(() => {
if (list.length) return list
return props.data.child_replies
})
let activeComment = $ref<DiscussCommentItem>()
function handleReply(data: DiscussCommentItem) {
activeComment = data
formVisible = true
form.content = `回复@${getUsername(data.sso_user)}:`
}
let formVisible = $ref(false)
const formRef = $ref<FormInstance>()
const form = reactive({
content: '',
files: []
})
const rules = ref<FormRules>({
content: [{ required: true, message: '请输入回复内容', trigger: 'blur' }]
})
// 发布回复
function handleSubmit() {
formRef?.validate().then(() => {
replyToPost({
id: props.data.discussion_id,
reply_type: 2,
floor_id: activeComment.floor_id,
reply_id: activeComment.id,
content: form.content.replace(`回复@${getUsername(activeComment.sso_user)}:`, '')
}).then(() => {
refetch()
ElMessage({ message: '回复成功', type: 'success' })
})
})
}
function getUsername(data: User) {
return data.realname || data.username || data.nickname
}
</script>
<template>
<div class="discuss-comment-list">
<div class="discuss-comment-item" v-for="item in currentList" :key="item.id">
<img
:src="item.sso_user.avatar || 'https://webapp-pub.ezijing.com/website/base/images/default.jpg'"
class="discuss-comment-item__avatar" />
<div class="discuss-comment-item__main">
<div class="discuss-comment-item-hd">
{{ getUsername(item.sso_user) }}
<span v-if="item.reply_sso_user?.id">
回复 <em>{{ getUsername(item.reply_sso_user) }}</em
>
</span>
</div>
<div class="discuss-comment-item-bd">{{ item.content }}</div>
<div class="discuss-comment-item-ft">
<p>来自于{{ item.created_time }}</p>
<div class="discuss-item__comment" @click="handleReply(item)"><p>回复</p></div>
</div>
</div>
</div>
<p class="more"><span @click="loadMore">查看更多</span></p>
<div class="discuss-item-form" v-if="formVisible">
<el-form ref="formRef" :model="form" :rules="rules" hide-required-asterisk>
<el-form-item prop="content">
<el-input type="textarea" :autosize="{ minRows: 6, maxRows: 6 }" v-model="form.content" />
</el-form-item>
<el-row justify="end">
<el-button round type="primary" auto-insert-space @click="handleSubmit">发表</el-button>
</el-row>
</el-form>
</div>
</div>
</template>
<style lang="scss">
.discuss-comment-list {
margin-top: 20px;
padding: 20px;
background-color: #f4f5f8;
border-radius: 6px;
.discuss-item-form {
margin-left: 40px;
}
}
.discuss-comment-item {
display: flex;
margin-bottom: 24px;
}
.discuss-comment-item__main {
flex: 1;
margin-left: 10px;
}
.discuss-comment-item-hd {
font-size: 16px;
color: #666666;
em {
color: #3571e0;
}
}
.discuss-comment-item-bd {
margin-top: 12px;
font-size: 16px;
color: #666666;
}
.discuss-comment-item-ft {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
font-size: 12px;
color: #b4b4b4;
}
.discuss-comment-item__avatar {
width: 30px;
height: 30px;
border-radius: 50%;
overflow: hidden;
object-fit: cover;
}
.more {
padding: 10px;
line-height: 30px;
color: #3571e0;
text-align: center;
span {
cursor: pointer;
}
}
</style>
<script setup lang="ts">
import type { File } from '../types'
interface Props {
data: File
}
const props = defineProps<Props>()
const isVideo = $computed(() => {
return props.data.url.includes('.mp4')
})
</script>
<template>
<div class="file-item">
<video :src="data.url" controls v-if="isVideo"></video>
<img :src="data.url" v-else />
</div>
</template>
<style lang="scss">
.file-item {
img {
width: 160px;
height: 90px;
object-fit: cover;
}
video {
width: 400px;
}
}
</style>
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage } from 'element-plus'
import AppUpload from '@/components/base/AppUpload.vue'
import AppEditor from '@/components/base/AppEditor.vue'
import { createPost } from '../api'
import { useMapStore } from '@/stores/map'
import { useGetCourseList } from '@/composables/useGetCourseList'
const emit = defineEmits<{
(e: 'update'): void
(e: 'update:modelValue', visible: boolean): void
}>()
const { courses, courseId, chapters } = useGetCourseList()
const types = useMapStore().getMapValuesByKey('learning_discussion_type')
const formRef = $ref<FormInstance>()
const form = reactive({
semester_id: '',
course_id: '',
chapter_id: '',
type: '',
title: '',
content: '',
files: []
})
const rules = ref<FormRules>({
course_id: [{ required: true, message: '请选择课程', trigger: 'change' }],
chapter_id: [{ required: true, message: '请选择章节', trigger: 'change' }],
type: [{ required: true, message: '请选择类型', trigger: 'change' }],
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
content: [{ required: true, message: '请输入正文内容', trigger: 'blur' }]
})
watchEffect(() => {
courseId.value = form.course_id
const course = courses.value.find(item => item.course_id === form.course_id)
form.semester_id = course ? course.semester.id : ''
})
// 提交
function handleSubmit() {
formRef?.validate().then(create)
}
// 修改
const create = () => {
const params = Object.assign({}, form, { files: form.files.length ? JSON.stringify(form.files) : '' })
createPost(params).then(() => {
ElMessage({ message: '发布成功', type: 'success' })
emit('update')
emit('update:modelValue', false)
})
}
</script>
<template>
<el-dialog title="发帖" width="800px" @update:modelValue="$emit('update:modelValue')">
<el-form ref="formRef" :model="form" :rules="rules" hide-required-asterisk label-position="top">
<el-row justify="space-between">
<el-form-item label="相关课程" prop="course_id">
<el-select filterable v-model="form.course_id">
<el-option v-for="item in courses" :key="item.id" :label="item.name" :value="item.course_id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="相关章节" prop="chapter_id">
<el-select filterable v-model="form.chapter_id">
<el-option v-for="item in chapters" :key="item.id" :label="item.name" :value="item.id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="类型" prop="type">
<el-select filterable v-model="form.type">
<el-option v-for="item in types" :key="item.id" :label="item.label" :value="item.value"></el-option>
</el-select>
</el-form-item>
</el-row>
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" />
</el-form-item>
<el-form-item label="正文内容" prop="content">
<AppEditor v-model="form.content" :height="300" />
</el-form-item>
<el-form-item prop="files">
<AppUpload v-model="form.files">
<el-button size="default">上传图片/视频附件</el-button>
<template #tip
>支持最多上传10张图片,格式支持jpg,jpeg,png,2MB以内<br />视频最多上传1个,100Mb以内
</template>
</AppUpload>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSubmit">发布问题</el-button>
</el-form-item>
</el-form>
</el-dialog>
</template>
<script setup lang="ts">
import type { PostItem } from '../types'
import FileItem from './FileItem.vue'
import { useMapStore } from '@/stores/map'
interface Props {
data: PostItem
}
const props = defineProps<Props>()
const types = useMapStore().getMapValuesByKey('learning_discussion_type')
const typeText = $computed(() => {
return types.find(item => parseInt(item.value) === props.data.type)?.label || props.data.type
})
const typeColor = $computed(() => {
const map: Record<number, string> = { 1: '#396DD1', 2: '#6B9357', 3: '#E5A237' }
return map[props.data.type] || '#396DD1'
})
const username = $computed(() => {
const user = props.data.sso_user
return user.realname || user.nickname || user.username
})
</script>
<template>
<section class="post-item">
<div class="post-item-hd">
<p class="post-item__type" :class="`type-${data.type}`">{{ typeText }}</p>
<h2 class="post-item__title">{{ data.title }}</h2>
<p class="post-item__reply_count">{{ data.reply_count }}回帖</p>
</div>
<div class="post-item-bd">
<ul class="post-item__files" v-if="data.reply?.files.length">
<li v-for="(item, index) in data.reply.files" :key="index"><FileItem :data="item" /></li>
</ul>
</div>
<div class="post-item-ft">
<p class="post-item__username">{{ username }}</p>
<p class="post-item__time">{{ data.updated_time }}</p>
</div>
</section>
</template>
<style lang="scss">
.post-item {
padding: 30px 0;
border-bottom: 1px solid #e6e6e6;
}
.post-item-hd {
display: flex;
}
.post-item__type {
--type-color: v-bind(typeColor);
display: inline-block;
padding: 0 4px;
color: var(--type-color);
border: 1px solid var(--type-color);
height: 20px;
font-size: 14px;
line-height: 20px;
border-radius: 2px;
}
.post-item__title {
flex: 1;
margin: 0 14px;
font-size: 16px;
font-weight: 400;
line-height: 20px;
color: #666;
}
.post-item__reply_count {
font-size: 14px;
line-height: 20px;
color: #bcbcbc;
}
.post-item-bd {
margin-top: 16px;
}
.post-item-ft {
margin-top: 16px;
display: flex;
}
.post-item__username {
min-width: 130px;
font-size: 14px;
line-height: 1;
color: #bcbcbc;
}
.post-item__time {
font-size: 14px;
line-height: 1;
color: #bcbcbc;
}
.post-item__files {
display: flex;
}
</style>
<script setup lang="ts">
import type { TopPostItem } from '../types'
import { getTopPostList } from '../api'
let list = $ref<TopPostItem[]>([])
function fetchList() {
getTopPostList().then(res => {
list = res.data.list
})
}
onMounted(fetchList)
</script>
<template>
<section class="pined-post" v-if="list.length">
<section class="pined-post-item" v-for="item in list" :key="item.id">
<p class="t1">置顶</p>
<p class="t2">{{ item.title }}</p>
<p class="t3">
<span>{{ item.type }}</span>
</p>
<p class="t4">{{ item.updated_time }}</p>
</section>
</section>
</template>
<style lang="scss">
.pined-post {
padding-bottom: 16px;
border-bottom: 1px solid #e6e6e6;
}
.pined-post-item {
display: flex;
align-items: center;
padding: 14px 0;
.t1 {
padding: 0 4px;
height: 20px;
color: #fff;
font-size: 14px;
line-height: 20px;
text-align: center;
background: #d38846;
border-radius: 2px;
}
.t2 {
flex: 1;
margin: 0 14px;
font-size: 16px;
color: #666;
line-height: 30px;
}
.t3 {
min-width: 30%;
text-align: center;
span {
display: inline-block;
min-width: 90px;
height: 30px;
line-height: 30px;
color: #ba143e;
background: rgba(253, 235, 240, 0.39);
border: 1px solid #f296ac;
border-radius: 18px;
}
}
.t4 {
flex: 0 0 150px;
font-size: 14px;
color: #bcbcbc;
text-align: right;
}
}
</style>
......@@ -5,6 +5,9 @@ export const routes: Array<RouteRecordRaw> = [
{
path: '/bbs',
component: AppLayout,
children: [{ path: '', component: () => import('./views/Index.vue') }]
children: [
{ path: '', component: () => import('./views/Index.vue') },
{ path: ':id', component: () => import('./views/View.vue'), props: true }
]
}
]
export interface Post {
id: string
sso_id: string
sso_type: number
title: string
semester_id: string
class_id: string
course_id: string
chapter_id: string
type: number
is_top: number
top_time: string
is_hot: number
pv: number
uv: number
reply_count: number
created_time: string
updated_time: string
delete_time: string
}
export interface PostItem extends Post {
reply: {
id: string
discussion_id: string
content: string
created_time: string
updated_time: string
files: File[]
}
sso_user: User
}
export type TopPostItem = Pick<Post, 'id' | 'title' | 'type' | 'created_time' | 'updated_time' | 'top_time'>
export interface DiscussItem {
id: string
discussion_id: string
sso_id: string
sso_type: 2
content: string
files: File[]
is_first: number
reply_type: number
floor_id: string
reply_id: string
reply_sso_id: string
reply_sso_type: number
created_time: string
updated_time: string
delete_time: string
sso_user: User
child_replies: DiscussCommentItem[]
}
export interface DiscussCommentItem {
id: string
discussion_id: string
sso_id: string
sso_type: number
content: string
files: File[]
is_first: number
reply_type: number
floor_id: string
reply_id: string
reply_sso_id: string
reply_sso_type: number
created_time: string
updated_time: string
delete_time: string
sso_user: User
reply_sso_user: User
}
export interface User {
id: string
username: string
nickname: string
realname: string
avatar: string
discussion_level: number
}
export interface File {
name: string
url: string
type: string
size: string
upload_time: string
}
<script setup lang="ts"></script>
<script setup lang="ts">
import AppList from '@/components/base/AppList.vue'
import PostItem from '../components/PostItem.vue'
import PostPinned from '../components/PostPinned.vue'
import { bbsSearchByList, bbsOrderByList } from '@/utils/dictionary'
import { getPostList } from '../api'
import { useMapStore } from '@/stores/map'
import { useGetCourseList } from '@/composables/useGetCourseList'
<template></template>
const PostForm = defineAsyncComponent(() => import('../components/PostForm.vue'))
const { courses, courseId, chapters } = useGetCourseList()
const courseList = $computed(() => {
return [{ value: '', label: '全部课程' }, ...courses.value]
})
const chapterList = $computed(() => {
return [{ value: '', label: '全部章节' }, ...chapters.value]
})
const types = useMapStore().getMapValuesByKey('learning_discussion_type')
const currentTypes = $computed(() => {
return [{ label: '全部类型', value: '' }, ...types]
})
const appList = $ref<InstanceType<typeof AppList> | null>(null)
const params = reactive({ search_by: 0, order_by: 0, course_id: '', semester_id: '', chapter_id: '', type: '' })
// 列表配置
const listOptions = computed(() => {
return {
hasFilterButton: false,
remote: {
httpRequest: getPostList,
params,
beforeRequest(requestParams: any) {
if (params.course_id !== requestParams.course_id) {
requestParams.chapter_id = ''
}
params.course_id = requestParams.course_id || ''
courseId.value = params.course_id
return requestParams
},
callback(res: { total: number; data: any }) {
return { total: res.total, list: res.data }
}
},
filters: [
{
type: 'select',
prop: 'course_id',
placeholder: '请选择',
options: courseList,
labelKey: 'name',
valueKey: 'course_id'
},
{
type: 'select',
prop: 'chapter_id',
placeholder: '请选择',
options: chapterList,
labelKey: 'name',
valueKey: 'id'
},
{ type: 'select', prop: 'search_by', placeholder: '请选择', options: bbsSearchByList },
{ type: 'select', prop: 'type', placeholder: '请选择', options: currentTypes },
{ type: 'select', prop: 'order_by', placeholder: '请选择', options: bbsOrderByList }
]
}
})
// 刷新
function handleRefetch() {
appList?.refetch()
}
const postFormVisible = $ref(false)
</script>
<template>
<AppList v-bind="listOptions" ref="appList">
<template #header-prepend>
<el-button round type="primary" @click="postFormVisible = true">我要发帖</el-button>
</template>
<template #body="{ data }">
<!-- 置顶帖子 -->
<PostPinned></PostPinned>
<template v-if="data.length">
<PostItem :data="item" v-for="item in data" :key="item.id"></PostItem>
</template>
<el-empty description="暂无数据" v-else />
</template>
</AppList>
<PostForm v-model="postFormVisible" @update="handleRefetch" v-if="postFormVisible"></PostForm>
</template>
<style lang="scss" scoped>
:deep(.table-list-hd) {
padding: 30px 0 12px 30px;
background-color: #fff;
border-radius: 6px;
justify-content: space-between;
.table-list-filter {
flex: unset;
}
.el-select {
width: 180px;
}
}
:deep(.table-list-bd) {
padding: 20px;
background-color: #fff;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
}
:deep(.table-list-ft) {
padding: 20px;
background-color: #fff;
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
}
</style>
<script setup lang="ts">
import type { Post } from '../types'
import AppList from '@/components/base/AppList.vue'
import DiscussItem from '../components/DiscussItem.vue'
import { getPostAndDiscussList } from '../api'
const DiscussForm = defineAsyncComponent(() => import('../components/DiscussForm.vue'))
interface Props {
id: string
}
const props = defineProps<Props>()
let detail = $ref<Post>()
const appList = $ref<InstanceType<typeof AppList> | null>(null)
// 列表配置
const listOptions = computed(() => {
return {
hasFilterButton: false,
remote: {
httpRequest: getPostAndDiscussList,
params: { id: props.id },
callback(res: { total: number; data: any; info: any }) {
detail = res.info
return { total: res.total, list: res.data }
}
}
}
})
// 刷新
function handleRefetch() {
appList?.refetch()
}
const discussFormVisible = $ref(false)
</script>
<template>
<div class="bbs">
<div class="bbs-hd" v-if="detail">
<h1>{{ detail.title }}</h1>
<el-button round auto-insert-space>收藏</el-button>
<el-button round type="primary" auto-insert-space @click="discussFormVisible = true">回复</el-button>
</div>
<AppList v-bind="listOptions" ref="appList">
<template #body="{ data }">
<DiscussItem :data="item" v-for="item in data" :key="item.id"></DiscussItem>
</template>
</AppList>
</div>
<DiscussForm v-model="discussFormVisible" :id="id" @update="handleRefetch" v-if="discussFormVisible"></DiscussForm>
</template>
<style lang="scss">
.bbs {
background-color: #fff;
border: 1px solid #e5e5e5;
border-radius: 6px;
.table-list-ft {
padding: 40px;
}
}
.bbs-hd {
padding: 20px;
display: flex;
align-items: center;
border-bottom: 1px solid #e4e4e4;
h1 {
flex: 1;
}
}
</style>
......@@ -25,3 +25,22 @@ export const liveStatus = {
}
// 试题类型列表
export const liveStatusList = json2Array(liveStatus, false)
// 论坛检索范围
export const bbsSearchBy = {
0: '全部帖子',
1: '我发布的帖子',
2: '我回复的帖子'
}
// 论坛检索范围列表
export const bbsSearchByList = json2Array(bbsSearchBy)
// 论坛检索范围
export const bbsOrderBy = {
0: '默认排序',
1: '热度从高-低',
2: '时间由近-远',
3: '时间由远-近'
}
// 论坛检索范围列表
export const bbsOrderByList = json2Array(bbsOrderBy)
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论