add:
更新
This commit is contained in:
Generated
+152
-14
@@ -8,6 +8,7 @@
|
||||
"name": "web",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@cloudgeek/vue3-video-player": "^0.3.10",
|
||||
"@microsoft/signalr": "^10.0.0",
|
||||
"@vuelidate/core": "^2.0.3",
|
||||
"@vuelidate/validators": "^2.0.4",
|
||||
@@ -15,6 +16,7 @@
|
||||
"feather-icons": "^4.29.2",
|
||||
"idb": "^8.0.3",
|
||||
"pinia": "^3.0.3",
|
||||
"spark-md5": "^3.0.2",
|
||||
"vee-validate": "^4.15.1",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.5.1",
|
||||
@@ -29,6 +31,7 @@
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-plugin-vue": "~10.4.0",
|
||||
"globals": "^16.3.0",
|
||||
"hevue-img-preview": "^7.1.3",
|
||||
"jsdom": "^27.0.0",
|
||||
"prettier": "3.6.2",
|
||||
"vite": "^7.1.7",
|
||||
@@ -105,7 +108,6 @@
|
||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.3",
|
||||
@@ -570,6 +572,20 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudgeek/vue3-video-player": {
|
||||
"version": "0.3.10",
|
||||
"resolved": "https://registry.npmjs.org/@cloudgeek/vue3-video-player/-/vue3-video-player-0.3.10.tgz",
|
||||
"integrity": "sha512-QMpQK20fpUp1WcyIf+PxxW+0OwAx8TR/cGJg/axcVhMtcZvLmP+BPxdMQd5NKZwJ+DqY7ZHEgkfc+9Ng7Vjxuw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@multiavatar/multiavatar": "^1.0.5",
|
||||
"core-js": "^3.6.5",
|
||||
"event-emitter": "^0.3.5",
|
||||
"ismobilejs": "^1.1.1",
|
||||
"load-script": "^1.0.0",
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
||||
@@ -658,7 +674,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -705,7 +720,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1497,6 +1511,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@multiavatar/multiavatar": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@multiavatar/multiavatar/-/multiavatar-1.0.7.tgz",
|
||||
"integrity": "sha512-Yg9Uw57bmlErsWL0CSv4d6D4ZqVBE00OZmYr9MRgygoXZdboNtsEI6FbBRw1AY8l88Sm1ARcyojtlm2uwUn0Zg==",
|
||||
"license": "SEE LICENSE IN LICENSE"
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -2670,7 +2690,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2874,7 +2893,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.9",
|
||||
"caniuse-lite": "^1.0.30001746",
|
||||
@@ -3165,6 +3183,19 @@
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz",
|
||||
"integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"es5-ext": "^0.10.64",
|
||||
"type": "^2.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz",
|
||||
@@ -3418,6 +3449,46 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es5-ext": {
|
||||
"version": "0.10.64",
|
||||
"resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz",
|
||||
"integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"es6-iterator": "^2.0.3",
|
||||
"es6-symbol": "^3.1.3",
|
||||
"esniff": "^2.0.1",
|
||||
"next-tick": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/es6-iterator": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
|
||||
"integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"d": "1",
|
||||
"es5-ext": "^0.10.35",
|
||||
"es6-symbol": "^3.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/es6-symbol": {
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz",
|
||||
"integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d": "^1.0.2",
|
||||
"ext": "^1.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz",
|
||||
@@ -3489,7 +3560,6 @@
|
||||
"integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -3551,7 +3621,6 @@
|
||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
@@ -3688,6 +3757,21 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/esniff": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz",
|
||||
"integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d": "^1.0.1",
|
||||
"es5-ext": "^0.10.62",
|
||||
"event-emitter": "^0.3.5",
|
||||
"type": "^2.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/espree": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
|
||||
@@ -3771,6 +3855,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/event-emitter": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
|
||||
"integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"d": "1",
|
||||
"es5-ext": "~0.10.14"
|
||||
}
|
||||
},
|
||||
"node_modules/event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
@@ -3826,6 +3920,15 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ext": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz",
|
||||
"integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"type": "^2.7.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -4258,6 +4361,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hevue-img-preview": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/hevue-img-preview/-/hevue-img-preview-7.1.3.tgz",
|
||||
"integrity": "sha512-nLB1eyPUqP2UysJpEJaboHMGKPY8zgsyqaPSUHjxr04jDc2PIg61KFFS/UHc0aa2xpbf5UaeCNoVOHFYdWVOTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"vue": "^3.5.17"
|
||||
}
|
||||
},
|
||||
"node_modules/hookable": {
|
||||
"version": "5.5.3",
|
||||
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
|
||||
@@ -4537,6 +4650,12 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ismobilejs": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz",
|
||||
"integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jackspeak": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||
@@ -4733,6 +4852,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/load-script": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/load-script/-/load-script-1.0.0.tgz",
|
||||
"integrity": "sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -4917,6 +5042,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next-tick": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz",
|
||||
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
@@ -5273,7 +5404,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -5313,7 +5443,6 @@
|
||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -5655,6 +5784,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/spark-md5": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/spark-md5/-/spark-md5-3.0.2.tgz",
|
||||
"integrity": "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==",
|
||||
"license": "(WTFPL OR MIT)"
|
||||
},
|
||||
"node_modules/speakingurl": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
|
||||
@@ -5937,7 +6072,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -6063,6 +6197,12 @@
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/type": {
|
||||
"version": "2.7.3",
|
||||
"resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",
|
||||
"integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -6232,7 +6372,6 @@
|
||||
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -6494,7 +6633,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -6508,7 +6646,6 @@
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -6594,7 +6731,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
|
||||
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.22",
|
||||
"@vue/compiler-sfc": "3.5.22",
|
||||
@@ -6624,6 +6760,7 @@
|
||||
"integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"eslint-scope": "^8.2.0",
|
||||
@@ -6648,6 +6785,7 @@
|
||||
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cloudgeek/vue3-video-player": "^0.3.10",
|
||||
"@microsoft/signalr": "^10.0.0",
|
||||
"@vuelidate/core": "^2.0.3",
|
||||
"@vuelidate/validators": "^2.0.4",
|
||||
@@ -22,6 +23,7 @@
|
||||
"feather-icons": "^4.29.2",
|
||||
"idb": "^8.0.3",
|
||||
"pinia": "^3.0.3",
|
||||
"spark-md5": "^3.0.2",
|
||||
"vee-validate": "^4.15.1",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.5.1",
|
||||
@@ -36,6 +38,7 @@
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-plugin-vue": "~10.4.0",
|
||||
"globals": "^16.3.0",
|
||||
"hevue-img-preview": "^7.1.3",
|
||||
"jsdom": "^27.0.0",
|
||||
"prettier": "3.6.2",
|
||||
"vite": "^7.1.7",
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div class="video-msg-container" @click="handlePlay">
|
||||
<img :src="thumbnailUrl" class="video-poster" :style="containerStyle" />
|
||||
|
||||
<div class="play-icon-overlay">
|
||||
<div class="play-button"></div>
|
||||
</div>
|
||||
|
||||
<span v-if="duration" class="duration-label">
|
||||
{{ formatDuration(duration) }}
|
||||
</span>
|
||||
|
||||
<div v-if="uploading" class="upload-mask">
|
||||
<div class="progress">{{ progress }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
// 缩略图地址(可以是 URL 或 Blob 生成的 ObjectURL)
|
||||
thumbnailUrl: String,
|
||||
// 视频时长(秒)
|
||||
duration: Number,
|
||||
// 视频原始宽高(用于计算比例)
|
||||
w: Number,
|
||||
h: Number,
|
||||
uploading: Boolean,
|
||||
progress: Number
|
||||
});
|
||||
|
||||
const emit = defineEmits(['play']);
|
||||
|
||||
// 格式化时长:如 75 -> "01:15"
|
||||
const formatDuration = (seconds) => {
|
||||
if (!seconds) return '00:00';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// 计算容器尺寸,防止长视频或宽视频撑破布局
|
||||
const containerStyle = computed(() => {
|
||||
const maxSide = 200; // 最大显示尺寸
|
||||
if (props.w > props.h) {
|
||||
return { width: maxSide + 'px', height: 'auto', aspectRatio: `${props.w}/${props.h}` };
|
||||
} else {
|
||||
return { height: maxSide + 'px', width: 'auto', aspectRatio: `${props.w}/${props.h}` };
|
||||
}
|
||||
});
|
||||
|
||||
const handlePlay = () => {
|
||||
emit('play');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.video-msg-container {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background-color: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.video-poster {
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.play-icon-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.play-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.play-button::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 10px;
|
||||
border-style: solid;
|
||||
border-width: 10px 0 10px 15px;
|
||||
border-color: transparent transparent transparent #333;
|
||||
}
|
||||
|
||||
.duration-label {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 6px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.upload-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
|
||||
<div id="Video"></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import Player from 'xgplayer/dist/simple_player';
|
||||
import volume from 'xgplayer/dist/controls/volume';
|
||||
import playbackRate from 'xgplayer/dist/controls/playbackRate';
|
||||
import { defineProps } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
m: {
|
||||
type: Object,
|
||||
required:true
|
||||
}
|
||||
});
|
||||
|
||||
let player = new Player({
|
||||
id: 'Video',
|
||||
url: props.m.content.url,
|
||||
controlPlugins: [
|
||||
volume,
|
||||
playbackRate
|
||||
],
|
||||
playbackRate: [0.5, 0.75, 1, 1.5, 2] //传入倍速可选数组
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div
|
||||
class="voice-card"
|
||||
:class="{ 'playing': isPlaying }"
|
||||
:style="{ width: bubbleWidth + 'px' }"
|
||||
@click="togglePlay"
|
||||
>
|
||||
<div class="play-icon-wrap">
|
||||
<svg v-if="!isPlaying" viewBox="0 0 24 24" class="svg-obj">
|
||||
<path fill="currentColor" d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" class="svg-obj">
|
||||
<path fill="currentColor" d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="wave-track">
|
||||
<div v-for="n in 40" :key="n" class="wave-item"></div>
|
||||
</div>
|
||||
|
||||
<span class="time-label">{{ Math.floor(duration) }}''</span>
|
||||
|
||||
<div v-if="!isRead" class="unread-dot"></div>
|
||||
|
||||
<audio ref="audioRef" :src="url" @ended="stopPlay" @error="stopPlay"></audio>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
url: { type: String, required: true },
|
||||
duration: { type: Number, default: 0 },
|
||||
isRead: { type: Boolean, default: true }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['played']);
|
||||
const audioRef = ref(null);
|
||||
const isPlaying = ref(false);
|
||||
|
||||
const bubbleWidth = computed(() => Math.min(110 + props.duration * 4, 260));
|
||||
|
||||
const togglePlay = (e) => {
|
||||
e.stopPropagation();
|
||||
if (isPlaying.value) {
|
||||
audioRef.value.pause();
|
||||
stopPlay();
|
||||
} else {
|
||||
audioRef.value.play();
|
||||
isPlaying.value = true;
|
||||
if (!props.isRead) emit('played');
|
||||
}
|
||||
};
|
||||
|
||||
const stopPlay = () => {
|
||||
isPlaying.value = false;
|
||||
if (audioRef.value) audioRef.value.currentTime = 0;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 核心改动:纯白背景 + 物理阴影 + 明确边框 */
|
||||
.voice-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
padding: 0 14px;
|
||||
background: #ffffff; /* 强迫其在灰白背景上显现 */
|
||||
border: 1.5px solid #e8eaed; /* 更有质感的深色边框 */
|
||||
border-radius: 20px; /* 全圆角更现代 */
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
user-select: none;
|
||||
color: #202124;
|
||||
/* 增加微妙的投影,产生高度感 */
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.voice-card:hover {
|
||||
border-color: #007aff;
|
||||
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.12);
|
||||
}
|
||||
|
||||
.voice-card:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
/* 播放图标:使用品牌蓝 */
|
||||
.play-icon-wrap {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #007aff;
|
||||
}
|
||||
.svg-obj { width: 100%; height: 100%; }
|
||||
|
||||
/* 波形区 */
|
||||
.wave-track {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin: 0 12px;
|
||||
height: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wave-item {
|
||||
width: 2px;
|
||||
height: 4px;
|
||||
background-color: #bdc1c6; /* 加深线条颜色,防止看不清 */
|
||||
border-radius: 1px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* 播放态动画 */
|
||||
.playing .wave-item {
|
||||
background-color: #007aff;
|
||||
animation: bar-throb 0.7s infinite alternate;
|
||||
}
|
||||
|
||||
/* 使用 nth-child 赋予不同的波浪起伏感 */
|
||||
.wave-item:nth-child(4n+1) { height: 6px; animation-delay: 0.1s; }
|
||||
.wave-item:nth-child(4n+2) { height: 14px; animation-delay: 0.3s; }
|
||||
.wave-item:nth-child(4n+3) { height: 10px; animation-delay: 0.2s; }
|
||||
.wave-item:nth-child(4n+4) { height: 6px; animation-delay: 0.4s; }
|
||||
|
||||
@keyframes bar-throb {
|
||||
from { transform: scaleY(1); opacity: 0.7; }
|
||||
to { transform: scaleY(1.8); opacity: 1; }
|
||||
}
|
||||
|
||||
.time-label {
|
||||
font-size: 13px;
|
||||
font-weight: 800; /* 加粗时间,更清晰 */
|
||||
color: #5f6368;
|
||||
flex-shrink: 0;
|
||||
font-family: 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
/* 未读红点:增加发光效果 */
|
||||
.unread-dot {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
background: #f44336;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 1px 4px rgba(244, 67, 54, 0.4);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
export const getMessageType = (fileType) => {
|
||||
if (!fileType) return FILE_TYPE.File; // 兜底处理
|
||||
|
||||
// 处理图片
|
||||
if (fileType.startsWith('image/')) {
|
||||
return FILE_TYPE.Image;
|
||||
}
|
||||
|
||||
// 处理音频(录音消息)
|
||||
if (fileType.startsWith('audio/')) {
|
||||
return FILE_TYPE.Voice;
|
||||
}
|
||||
|
||||
// 处理视频
|
||||
if (fileType.startsWith('video/')) {
|
||||
return FILE_TYPE.Video;
|
||||
}
|
||||
|
||||
// 常见文档类型的特殊处理(可选)
|
||||
const documentTypes = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'text/plain'
|
||||
];
|
||||
|
||||
if (documentTypes.includes(fileType)) {
|
||||
return FILE_TYPE.File;
|
||||
}
|
||||
|
||||
// 其他所有情况统一归类为文件
|
||||
return FILE_TYPE.File;
|
||||
};
|
||||
|
||||
export const FILE_TYPE = Object.freeze({
|
||||
Image: 'Image',
|
||||
Video: 'Video',
|
||||
Voice: 'Voice',
|
||||
File: 'File'
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
export class MessageBaseInfo {
|
||||
constructor(format, text) {
|
||||
this.format = format;
|
||||
this.text = text;
|
||||
}
|
||||
}
|
||||
|
||||
export class ImageInfo extends MessageBaseInfo {
|
||||
constructor(format, text, w, h, Thumb) {
|
||||
super(format, text);
|
||||
this.w = w;
|
||||
this.h = h;
|
||||
this.thumb = Thumb;
|
||||
}
|
||||
}
|
||||
|
||||
export class VideoInfo extends ImageInfo {
|
||||
constructor(format, text, w, h, Thumb, duration) {
|
||||
super(format, text, w, h, Thumb);
|
||||
this.duration = duration;
|
||||
}
|
||||
}
|
||||
|
||||
export class VoiceInfo extends MessageBaseInfo {
|
||||
constructor(format, text, duration) {
|
||||
super(format, text);
|
||||
this.duration = duration;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
|
||||
export const UPLOAD_STATUS = Object.freeze({
|
||||
UPLOADING: 'uploading',
|
||||
UPLOADED: 'uploaded',
|
||||
MERGING: 'merging',
|
||||
COMPLETE: 'complete'
|
||||
})
|
||||
@@ -7,10 +7,16 @@ import router from './router'
|
||||
import MyButton from './components/MyButton.vue'
|
||||
import IconInput from './components/IconInput.vue'
|
||||
|
||||
import Vue3VideoPlayer from '@cloudgeek/vue3-video-player'
|
||||
import '@cloudgeek/vue3-video-player/dist/vue3-video-player.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(Vue3VideoPlayer, {
|
||||
lang: 'zh-CN' // 可选,语言包
|
||||
})
|
||||
app.component('MyButton', MyButton)
|
||||
app.component('IconInput', IconInput)
|
||||
|
||||
|
||||
@@ -5,99 +5,100 @@ import { useAuthStore } from '@/stores/auth';
|
||||
import { authService } from './auth';
|
||||
|
||||
const message = useMessage();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
let waitqueue = [];
|
||||
let isRefreshing = false;
|
||||
const authURL = ['/auth/login', '/auth/register', '/auth/refresh'];
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api', // 从环境变量中读取基础 URL
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api', // 从环境变量中读取基础 URL
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
api.interceptors.request.use(
|
||||
config => {
|
||||
const token = authStore.token;
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
err => {
|
||||
return Promise.reject(err);
|
||||
config => {
|
||||
const authStore = useAuthStore();
|
||||
const token = authStore.token;
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
err => {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
)
|
||||
|
||||
api.interceptors.response.use(
|
||||
response => {
|
||||
return response.data;
|
||||
},
|
||||
async err => {
|
||||
const { config, response } = err;
|
||||
if (response) {
|
||||
switch (response.status) {
|
||||
case 401:
|
||||
if (authURL.some(x => config.url.includes(x))) {
|
||||
authStore.logout();
|
||||
message.error('未登录,请登录后操作。');
|
||||
router.push('/auth/login')
|
||||
break;
|
||||
}
|
||||
if (config._retry) {
|
||||
break;
|
||||
}
|
||||
response => {
|
||||
return response.data;
|
||||
},
|
||||
async err => {
|
||||
const authStore = useAuthStore();
|
||||
const { config, response } = err;
|
||||
if (response) {
|
||||
switch (response.status) {
|
||||
case 401:
|
||||
if (authURL.some(x => config.url.includes(x))) {
|
||||
authStore.logout();
|
||||
message.error('未登录,请登录后操作。');
|
||||
router.push('/auth/login')
|
||||
break;
|
||||
}
|
||||
if (config._retry) {
|
||||
break;
|
||||
}
|
||||
|
||||
config._retry = true;
|
||||
// 已经在刷新 → 排队
|
||||
if (isRefreshing) {
|
||||
return new Promise(resolve => {
|
||||
waitqueue.push(token => {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
resolve(api(config))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
const refreshToken = authStore.refreshToken;
|
||||
if (refreshToken != null && refreshToken != '') {
|
||||
const res = await authService.refresh(refreshToken)
|
||||
authStore.setLoginInfo(res.data.token, res.data.refreshToken, res.data.userInfo)
|
||||
waitqueue.forEach(cb => cb(authStore.token));
|
||||
waitqueue = [];
|
||||
config.headers.Authorization = `Bearer ${authStore.token}`
|
||||
return api(config)
|
||||
}
|
||||
authStore.logout();
|
||||
message.error('未登录,请登录后操作。');
|
||||
router.push('/auth/login')
|
||||
break;
|
||||
case 400:
|
||||
if (response.data && response.data.code == 1003) {
|
||||
message.error(response.data.message);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
message.error('请求错误,请检查网络。');
|
||||
break;
|
||||
}
|
||||
return Promise.reject(err);
|
||||
} else {
|
||||
message.error('请求错误,请检查网络。');
|
||||
return Promise.reject(err);
|
||||
}
|
||||
config._retry = true;
|
||||
// 已经在刷新 → 排队
|
||||
if (isRefreshing) {
|
||||
return new Promise(resolve => {
|
||||
waitqueue.push(token => {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
resolve(api(config))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
const refreshToken = authStore.refreshToken;
|
||||
if (refreshToken != null && refreshToken != '') {
|
||||
const res = await authService.refresh(refreshToken)
|
||||
authStore.setLoginInfo(res.data.token, res.data.refreshToken, res.data.userInfo)
|
||||
waitqueue.forEach(cb => cb(authStore.token));
|
||||
waitqueue = [];
|
||||
config.headers.Authorization = `Bearer ${authStore.token}`
|
||||
return api(config)
|
||||
}
|
||||
authStore.logout();
|
||||
message.error('未登录,请登录后操作。');
|
||||
router.push('/auth/login')
|
||||
break;
|
||||
case 400:
|
||||
if (response.data && response.data.code == 1003) {
|
||||
message.error(response.data.message);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
message.error('请求错误,请检查网络。');
|
||||
break;
|
||||
}
|
||||
return Promise.reject(err);
|
||||
} else {
|
||||
message.error('请求错误,请检查网络。');
|
||||
return Promise.reject(err);
|
||||
}
|
||||
|
||||
}
|
||||
)
|
||||
|
||||
export const request = {
|
||||
get: (url, config) => api.get(url, config),
|
||||
post: (url, data, config) => api.post(url, data, config),
|
||||
put: (url, data, config) => api.put(url, data, config),
|
||||
delete: (url, config) => api.delete(url, config),
|
||||
instance: api,
|
||||
};
|
||||
get: (url, config) => api.get(url, config),
|
||||
post: (url, data, config) => api.post(url, data, config),
|
||||
put: (url, data, config) => api.put(url, data, config),
|
||||
delete: (url, config) => api.delete(url, config),
|
||||
instance: api,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { request } from "../api";
|
||||
|
||||
export const uploadService = {
|
||||
/**
|
||||
* 创建文件上传任务
|
||||
* @param {*} fileName 文件名
|
||||
* @param {*} fileSize 文件大小
|
||||
* @param {*} contentType 文件类型
|
||||
* @param {*} fileHash 文件哈希
|
||||
* @returns
|
||||
*/
|
||||
createUploadTask: (fileName, fileSize, contentType, fileHash) => request.post('/Upload/CreateTask', {
|
||||
fileName: fileName,
|
||||
fileSize: fileSize,
|
||||
contentType: contentType,
|
||||
fileHash: fileHash
|
||||
}),
|
||||
/**
|
||||
* 创建分段任务
|
||||
* @param {*} taskId 任务ID
|
||||
* @param {*} partNum 分段序号
|
||||
* @returns
|
||||
*/
|
||||
createPartTask: (taskId, partNum) => request.post(`/Upload/CreatePart?taskId=${taskId}&partNum=${partNum}`),
|
||||
|
||||
completeTask: (taskId, data) => request.post(`/Upload/CompleteTask?taskId=${taskId}`, data),
|
||||
|
||||
uploadPart: (uploadUrl, headers, file, onProgress) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
return request.post(
|
||||
uploadUrl,
|
||||
formData,
|
||||
{
|
||||
baseURL: '',
|
||||
headers, // 不要包含 Content-Type
|
||||
onUploadProgress: e => {
|
||||
if (onProgress && e.total) {
|
||||
onProgress(e.loaded / e.total)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
uploadSmallFile: (file, hash) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return request.post(`/Upload/upload/${hash}`, formData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { reactive } from "vue";
|
||||
import { uploadService } from "./uploadService";
|
||||
import { getFileHash, sliceFile } from "@/utils/uploadTools";
|
||||
import { request } from "../api";
|
||||
import { UPLOAD_STATUS } from "@/constants/uploadStatus";
|
||||
|
||||
|
||||
export const uploadFile = async (file, {
|
||||
onProgress,
|
||||
onPartComplete
|
||||
} = {}) => {
|
||||
|
||||
const fileHash = await getFileHash(file);
|
||||
const { taskId, chunkSize, concurrency, skip, url } = (await uploadService.createUploadTask(file.name, file.size, file.type, fileHash)).data;
|
||||
|
||||
if (skip) {
|
||||
const uploadStatus = {
|
||||
status: UPLOAD_STATUS.COMPLETE,
|
||||
progress: 100,
|
||||
taskId: taskId,
|
||||
url: url
|
||||
}
|
||||
onProgress?.(uploadStatus)
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks = sliceFile(file, chunkSize);
|
||||
|
||||
const comleteData = [];
|
||||
|
||||
let chunkProgress = reactive(new Array(chunks.length).fill(0))
|
||||
|
||||
const tasks = chunks.map((chunk, index) => {
|
||||
return async () => {
|
||||
const partNum = index + 1;
|
||||
const { skip, method, url, headers, partNumber } = (await uploadService.createPartTask(taskId, partNum)).data;
|
||||
if (!skip) {
|
||||
const { data } = await uploadService.uploadPart(url, headers, chunks[index], p => {
|
||||
chunkProgress[index] = p;
|
||||
// 第三步:计算总进度
|
||||
// 把账本上所有的百分比加起来
|
||||
const sum = chunkProgress.reduce((acc, cur) => acc + cur, 0);
|
||||
const total = (sum / chunks.length) * 100;
|
||||
const displayTotal = total.toFixed(2);
|
||||
const uploadStatus = {
|
||||
status: displayTotal == 100 ? UPLOAD_STATUS.UPLOADED : UPLOAD_STATUS.UPLOADING,
|
||||
progress: displayTotal,
|
||||
taskId: taskId,
|
||||
url: null
|
||||
}
|
||||
onProgress?.(uploadStatus)
|
||||
});
|
||||
onPartComplete?.(partNum)
|
||||
return data;
|
||||
} else {
|
||||
return { skip, partNumber };
|
||||
}
|
||||
}
|
||||
});
|
||||
const results = await concurrentUpload(tasks, concurrency);
|
||||
|
||||
const errors = results.filter(r => r.status === 'rejected');
|
||||
|
||||
if (errors.length > 0) return;
|
||||
|
||||
await uploadService.completeTask(taskId, comleteData);
|
||||
|
||||
const evtSource = new EventSource(`${request.instance.defaults.baseURL}/upload/events/${taskId}`);
|
||||
|
||||
evtSource.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
const uploadStatus = {
|
||||
status: data.progress == 100 ? UPLOAD_STATUS.COMPLETE : UPLOAD_STATUS.MERGING,
|
||||
taskId: taskId,
|
||||
progress: data.progress,
|
||||
url: data.url
|
||||
}
|
||||
onProgress?.(uploadStatus)
|
||||
|
||||
if (data.status === "Completed") {
|
||||
evtSource.close();
|
||||
}
|
||||
};
|
||||
|
||||
evtSource.onerror = (err) => {
|
||||
console.error("SSE 连接异常", err);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const concurrentUpload = async (tasks, limit = 3, maxRetries = 3) => {
|
||||
const results = [];
|
||||
const executing = [];
|
||||
for (const task of tasks) {
|
||||
const retryTask = async (task) => {
|
||||
let attempt = 0;
|
||||
while (attempt <= maxRetries) {
|
||||
try {
|
||||
return await task();
|
||||
} catch (e) {
|
||||
attempt++;
|
||||
if (attempt > maxRetries) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const p = Promise.resolve().then(() => retryTask(task));
|
||||
results.push(p);
|
||||
|
||||
if (limit <= tasks.length) {
|
||||
const e = p.finally(() => executing.splice(executing.indexOf(e), 1));
|
||||
executing.push(e);
|
||||
|
||||
if (executing.length >= limit) {
|
||||
await Promise.race(executing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.allSettled(results);
|
||||
}
|
||||
+113
-110
@@ -4,115 +4,118 @@ import { messageService } from "@/services/message";
|
||||
import { useConversationStore } from "./conversation";
|
||||
|
||||
export const useChatStore = defineStore('chat', {
|
||||
state: () => ({
|
||||
activeConversationId: null,
|
||||
activeSessionId: null,
|
||||
maxSequenceId: null,
|
||||
isEnded: false,
|
||||
messages: [],
|
||||
pageSize: 20
|
||||
}),
|
||||
actions: {
|
||||
// 抽取统一的排序去重方法
|
||||
async pushAndSortMessagesAsync(newMsgs, sessionId, shouldSaveToDb = true) {
|
||||
if (shouldSaveToDb) {
|
||||
for (const m of newMsgs) {
|
||||
await messagesDb.save({ ...m, sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionId == this.activeSessionId) {
|
||||
const combined = [...this.messages, ...newMsgs];
|
||||
// 1. 根据 msgId 或唯一 key 去重
|
||||
const uniqueMap = new Map();
|
||||
combined.forEach(m => uniqueMap.set(m.msgId || m.sequenceId, m));
|
||||
|
||||
// 2. 转换为数组并按sequenceId升序排序 (旧的在前,新的在后)
|
||||
this.messages = Array.from(uniqueMap.values()).sort((a, b) => {
|
||||
return a.sequenceId - b.sequenceId;
|
||||
});
|
||||
this.maxSequenceId = this.messages.reduce((max, m) =>
|
||||
m.sequenceId > max ? m.sequenceId : max,
|
||||
null // 初始值
|
||||
);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 切换会话加载当前会话消息列表
|
||||
* @param {*} sessionId
|
||||
*/
|
||||
async swtichSession(sessionId, conversationId) {
|
||||
this.activeSessionId = sessionId;
|
||||
this.activeConversationId = conversationId;
|
||||
this.messages = [];
|
||||
this.isEnded = false;
|
||||
//先从浏览器缓存加载一部分消息列表
|
||||
const localHistory = await messagesDb.getLatestMessages(sessionId, this.pageSize);
|
||||
console.log(localHistory)
|
||||
if (localHistory.length > 0) {
|
||||
this.messages = localHistory;
|
||||
this.maxSequenceId = this.messages.reduce((max, m) =>
|
||||
m.sequenceId > max ? m.sequenceId : max,
|
||||
null // 初始值
|
||||
);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 从服务器加载新消息
|
||||
* @param {*} sessionId
|
||||
* @returns
|
||||
*/
|
||||
async fetchNewMsgFromServier(conversationId, sequenceId) {
|
||||
const newMsg = (await messageService.getMessages(conversationId, sequenceId, sequenceId ? 1 : 0, this.pageSize)).data;
|
||||
if (newMsg.length > 0) {
|
||||
return newMsg;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 从服务器加载历史消息
|
||||
* @param {*} sessionId
|
||||
* @param {*} msgId
|
||||
* @returns
|
||||
*/
|
||||
async fetchHistoryFromServer(conversationId, sequenceId) {
|
||||
const res = (await messageService.getMessages(conversationId, sequenceId, 0, this.pageSize)).data;
|
||||
|
||||
if (res.length > 0) {
|
||||
const sessionId = this.activeSessionId;
|
||||
return res;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 加载更多历史消息
|
||||
*/
|
||||
async loadMoreMessages() {
|
||||
let minSequenceId = 0;
|
||||
if (!this.messages || this.messages.length === 0) return;
|
||||
minSequenceId = this.messages.reduce((min, m) =>
|
||||
(m.sequenceId < min ? m.sequenceId : min),
|
||||
this.messages[0].sequenceId // 使用第一项作为初始参考值
|
||||
);
|
||||
const dbCacheList = await messagesDb.getPageMessages(this.activeSessionId, minSequenceId, this.pageSize)
|
||||
const dbMaxSequenceId = dbCacheList.reduce((max, m) =>
|
||||
m.sequenceId > max ? m.sequenceId : max,
|
||||
null // 初始值
|
||||
);
|
||||
if (dbCacheList.length < this.pageSize) {
|
||||
const newList = await this.fetchHistoryFromServer(this.activeConversationId, minSequenceId)
|
||||
if (newList.length === 0) this.isEnded = true;
|
||||
await this.pushAndSortMessagesAsync(newList, this.activeSessionId, true);
|
||||
} else if (dbMaxSequenceId < minSequenceId - 1) {
|
||||
const newList = await this.fetchHistoryFromServer(this.activeConversationId, minSequenceId)
|
||||
if (newList.length === 0) this.isEnded = true;
|
||||
await this.pushAndSortMessagesAsync(newList, this.activeSessionId, true);
|
||||
}
|
||||
else {
|
||||
await this.pushAndSortMessagesAsync(dbCacheList, this.activeSessionId, false);
|
||||
}
|
||||
state: () => ({
|
||||
activeConversationId: null,
|
||||
activeSessionId: null,
|
||||
maxSequenceId: null,
|
||||
isEnded: false,
|
||||
messages: [],
|
||||
pageSize: 20
|
||||
}),
|
||||
actions: {
|
||||
// 抽取统一的排序去重方法
|
||||
async pushAndSortMessagesAsync(newMsgs, sessionId, shouldSaveToDb = true) {
|
||||
if (shouldSaveToDb) {
|
||||
for (const m of newMsgs) {
|
||||
if (m.type != 'Text' && !m.isLoading && !m.isError && !m.isImgLoading) {
|
||||
m.content = JSON.parse(m.content);
|
||||
}
|
||||
await messagesDb.save({ ...m, sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionId == this.activeSessionId) {
|
||||
const combined = [...this.messages, ...newMsgs];
|
||||
// 1. 根据 msgId 或唯一 key 去重
|
||||
const uniqueMap = new Map();
|
||||
combined.forEach(m => uniqueMap.set(m.msgId || m.sequenceId, m));
|
||||
|
||||
// 2. 转换为数组并按sequenceId升序排序 (旧的在前,新的在后)
|
||||
this.messages = Array.from(uniqueMap.values()).sort((a, b) => {
|
||||
return a.sequenceId - b.sequenceId;
|
||||
});
|
||||
this.maxSequenceId = this.messages.reduce((max, m) =>
|
||||
m.sequenceId > max ? m.sequenceId : max,
|
||||
null // 初始值
|
||||
);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 切换会话加载当前会话消息列表
|
||||
* @param {*} sessionId
|
||||
*/
|
||||
async swtichSession(sessionId, conversationId) {
|
||||
this.activeSessionId = sessionId;
|
||||
this.activeConversationId = conversationId;
|
||||
this.messages = [];
|
||||
this.isEnded = false;
|
||||
//先从浏览器缓存加载一部分消息列表
|
||||
const localHistory = await messagesDb.getLatestMessages(sessionId, this.pageSize);
|
||||
console.log(localHistory)
|
||||
if (localHistory.length > 0) {
|
||||
this.messages = localHistory;
|
||||
this.maxSequenceId = this.messages.reduce((max, m) =>
|
||||
m.sequenceId > max ? m.sequenceId : max,
|
||||
null // 初始值
|
||||
);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 从服务器加载新消息
|
||||
* @param {*} sessionId
|
||||
* @returns
|
||||
*/
|
||||
async fetchNewMsgFromServier(conversationId, sequenceId) {
|
||||
const newMsg = (await messageService.getMessages(conversationId, sequenceId, sequenceId ? 1 : 0, this.pageSize)).data;
|
||||
if (newMsg.length > 0) {
|
||||
return newMsg;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 从服务器加载历史消息
|
||||
* @param {*} sessionId
|
||||
* @param {*} msgId
|
||||
* @returns
|
||||
*/
|
||||
async fetchHistoryFromServer(conversationId, sequenceId) {
|
||||
const res = (await messageService.getMessages(conversationId, sequenceId, 0, this.pageSize)).data;
|
||||
|
||||
if (res.length > 0) {
|
||||
const sessionId = this.activeSessionId;
|
||||
return res;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 加载更多历史消息
|
||||
*/
|
||||
async loadMoreMessages() {
|
||||
let minSequenceId = 0;
|
||||
if (!this.messages || this.messages.length === 0) return;
|
||||
minSequenceId = this.messages.reduce((min, m) =>
|
||||
(m.sequenceId < min ? m.sequenceId : min),
|
||||
this.messages[0].sequenceId // 使用第一项作为初始参考值
|
||||
);
|
||||
const dbCacheList = await messagesDb.getPageMessages(this.activeSessionId, minSequenceId, this.pageSize)
|
||||
const dbMaxSequenceId = dbCacheList.reduce((max, m) =>
|
||||
m.sequenceId > max ? m.sequenceId : max,
|
||||
null // 初始值
|
||||
);
|
||||
if (dbCacheList.length < this.pageSize) {
|
||||
const newList = await this.fetchHistoryFromServer(this.activeConversationId, minSequenceId)
|
||||
if (newList.length === 0) this.isEnded = true;
|
||||
await this.pushAndSortMessagesAsync(newList, this.activeSessionId, true);
|
||||
} else if (dbMaxSequenceId < minSequenceId - 1) {
|
||||
const newList = await this.fetchHistoryFromServer(this.activeConversationId, minSequenceId)
|
||||
if (newList.length === 0) this.isEnded = true;
|
||||
await this.pushAndSortMessagesAsync(newList, this.activeSessionId, true);
|
||||
}
|
||||
else {
|
||||
await this.pushAndSortMessagesAsync(dbCacheList, this.activeSessionId, false);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
export const loadImage = (url) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img); // 成功时返回 img 对象
|
||||
img.onerror = (err) => reject(err); // 失败时报错
|
||||
img.src = url;
|
||||
});
|
||||
};
|
||||
/**
|
||||
* 生成图片缩略图 (返回 Blob 文件)
|
||||
*/
|
||||
export function generateImageThumbnailBlob(img, maxWidth = 200) {
|
||||
return new Promise((resolve) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const scale = maxWidth / img.width;
|
||||
canvas.width = maxWidth;
|
||||
canvas.height = img.height * scale;
|
||||
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 关键:导出为 Blob
|
||||
canvas.toBlob((blob) => {
|
||||
resolve(blob);
|
||||
}, 'image/jpeg', 0.8);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取视频缩略图 (返回 Blob 文件)
|
||||
* 报错或无法渲染画面时自动生成全黑占位图
|
||||
*/
|
||||
export function getVideoThumbnailBlob(file, seekTime = 1) {
|
||||
return new Promise((resolve) => {
|
||||
const video = document.createElement('video');
|
||||
const url = URL.createObjectURL(file);
|
||||
video.muted = true;
|
||||
video.src = url;
|
||||
|
||||
// 统一定义一个生成黑色背景的方法
|
||||
const resolveBlackThumbnail = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
// 如果视频元数据拿不到宽高,给一个默认的 16:9 尺寸
|
||||
canvas.width = video.videoWidth || 320;
|
||||
canvas.height = video.videoHeight || 180;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#000000'; // 全黑
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(blob);
|
||||
}, 'image/jpeg', 0.8);
|
||||
};
|
||||
|
||||
video.onloadedmetadata = () => {
|
||||
// 检查:如果元数据加载了但没有画面尺寸(只有音频的视频常现)
|
||||
if (video.videoWidth === 0) {
|
||||
resolveBlackThumbnail();
|
||||
} else {
|
||||
video.currentTime = seekTime;
|
||||
}
|
||||
};
|
||||
|
||||
video.onseeked = () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// 尝试绘制,如果解码失败这里可能抛出异常
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
URL.revokeObjectURL(url);
|
||||
// 如果导出的 blob 大小极小(可能是空的),可以在这里进一步检查
|
||||
resolve(blob);
|
||||
}, 'image/jpeg', 0.8);
|
||||
} catch (e) {
|
||||
resolveBlackThumbnail();
|
||||
}
|
||||
};
|
||||
|
||||
// 报错处理:不再 reject,而是给一张黑图
|
||||
video.onerror = () => {
|
||||
console.warn("视频封面截取失败,已生成全黑占位图");
|
||||
resolveBlackThumbnail();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频时长
|
||||
* @param {File} file - 视频文件对象
|
||||
* @returns {Promise<number>} - 返回秒数 (float)
|
||||
*/
|
||||
export function getVideoDuration(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const video = document.createElement('video');
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
video.preload = 'metadata'; // 关键:只加载元数据
|
||||
video.src = url;
|
||||
|
||||
// 当元数据加载完成后触发
|
||||
video.onloadedmetadata = () => {
|
||||
const duration = video.duration;
|
||||
URL.revokeObjectURL(url); // 释放内存
|
||||
resolve(duration);
|
||||
};
|
||||
|
||||
video.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject("无法解析视频元数据");
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import SparkMD5 from "spark-md5";
|
||||
|
||||
export const getFileHash = (file) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
|
||||
const chunkSize = 2 * 1024 * 1024; // 每次读取 2MB
|
||||
const chunks = Math.ceil(file.size / chunkSize);
|
||||
let currentChunk = 0;
|
||||
const spark = new SparkMD5.ArrayBuffer();
|
||||
const fileReader = new FileReader();
|
||||
|
||||
fileReader.onload = (e) => {
|
||||
spark.append(e.target.result); // 将二进制数据添加到计算器
|
||||
currentChunk++;
|
||||
|
||||
if (currentChunk < chunks) {
|
||||
loadNext();
|
||||
} else {
|
||||
resolve(spark.end()); // 完成计算,返回最终结果
|
||||
}
|
||||
};
|
||||
|
||||
fileReader.onerror = () => {
|
||||
reject('文件读取出错');
|
||||
};
|
||||
|
||||
function loadNext() {
|
||||
const start = currentChunk * chunkSize;
|
||||
const end = start + chunkSize >= file.size ? file.size : start + chunkSize;
|
||||
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
|
||||
}
|
||||
|
||||
loadNext();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件分片
|
||||
* @param {*} file 文件
|
||||
* @param {*} chunkSize 分片大小
|
||||
* @returns
|
||||
*/
|
||||
export const sliceFile = (file, chunkSize) => {
|
||||
const chuncks = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < file.size) {
|
||||
chuncks.push(file.slice(index, index + chunkSize));
|
||||
index += chunkSize;
|
||||
}
|
||||
|
||||
return chuncks;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,38 +7,18 @@
|
||||
</div>
|
||||
|
||||
<div class="search-box">
|
||||
<input
|
||||
v-model="keyword"
|
||||
type="text"
|
||||
placeholder="搜索 ID / 手机号"
|
||||
@keyup.enter="onSearch"
|
||||
<input
|
||||
type="file"
|
||||
placeholder="搜索 ID / 手机号"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
<button class="search-btn" @click="onSearch" :loading="loading">
|
||||
<span v-if="!loading">搜索</span>
|
||||
<span v-else class="spinner"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<transition name="fade-slide">
|
||||
<div v-if="userResult" class="user-card">
|
||||
<div class="user-info">
|
||||
<img :src="userResult.avatar" class="avatar" />
|
||||
<div class="detail">
|
||||
<span class="name">{{ userResult.nickname }}</span>
|
||||
<span class="id">ID: {{ userResult.userId }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="add-action-btn" @click="handleAdd">添加好友</button>
|
||||
</div>
|
||||
<div v-else-if="hasSearched && !userResult" class="empty-state">
|
||||
未找到该用户,请检查输入是否正确
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { uploadFile } from '@/services/upload/uploader';
|
||||
import { ref } from 'vue';
|
||||
|
||||
// 接收父组件的 v-model 用于控制显示隐藏
|
||||
@@ -60,7 +40,7 @@ const close = () => {
|
||||
|
||||
const onSearch = async () => {
|
||||
if (!keyword.value) return;
|
||||
|
||||
|
||||
loading.value = true;
|
||||
hasSearched.value = false;
|
||||
|
||||
@@ -68,7 +48,7 @@ const onSearch = async () => {
|
||||
setTimeout(() => {
|
||||
loading.value = false;
|
||||
hasSearched.value = true;
|
||||
|
||||
|
||||
// 模拟搜索结果
|
||||
if (keyword.value === '12345') {
|
||||
userResult.value = {
|
||||
@@ -82,6 +62,16 @@ const onSearch = async () => {
|
||||
}, 600);
|
||||
};
|
||||
|
||||
|
||||
const handleFileChange = async (event) => {
|
||||
const file = event.target.files[0];
|
||||
uploadFile(file, {
|
||||
onProgress: (p) => {
|
||||
console.log(`当前进度:${p}%`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
emit('add-friend', userResult.value);
|
||||
// 通常这里会显示“已发送请求”提示
|
||||
@@ -229,4 +219,4 @@ const handleAdd = () => {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<span class="name">{{ s.targetName ?? '未知用户' }}</span>
|
||||
<span class="time">{{ formatDate(s.dateTime) ?? '1970/1/1 00:00:00' }}</span>
|
||||
</div>
|
||||
<div class="last-msg">{{ s.lastMessage ?? '获取消息内容失败' }}</div>
|
||||
<div class="last-msg">{{ lastMessageHandler(s.lastMessage) ?? '获取消息内容失败' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,13 +94,26 @@ function actionHandler(type){
|
||||
case 'addFriend':
|
||||
searchUserModal.value = true;
|
||||
break;
|
||||
case 'createGroup':
|
||||
case 'createGroup':
|
||||
createGroupModal.value = true;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function lastMessageHandler(text){
|
||||
try{
|
||||
const data = JSON.parse(text);
|
||||
if(data.text){
|
||||
return data.text;
|
||||
}else{
|
||||
return text
|
||||
}
|
||||
}catch(e){
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
const chatStore = useChatStore();
|
||||
|
||||
watch(
|
||||
|
||||
@@ -12,20 +12,84 @@
|
||||
<HistoryLoading ref="loadingRef" :loading="isLoading" :finished="isFinished" :error="hasError" @retry="loadHistoryMsg"/>
|
||||
<UserHoverCard ref="userHoverCardRef"/>
|
||||
<ContextMenu ref="menuRef"/>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="videoOpen" class="video-overlay" @click.self="videoOpen = false">
|
||||
|
||||
<div class="video-dialog">
|
||||
<div class="close-bar" @click="videoOpen = false">
|
||||
<span>正在播放视频</span>
|
||||
<button class="close-btn">×</button>
|
||||
</div>
|
||||
|
||||
<div class="player-wrapper">
|
||||
<vue3-video-player
|
||||
:src="videoUrl"
|
||||
poster="https://xxx.jpg"
|
||||
:controls="true"
|
||||
:autoplay="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<div v-for="m in chatStore.messages" :key="m.id" :class="['msg', m.senderId == myInfo.id ? 'mine' : 'other']">
|
||||
<img @mouseenter="(e) => handleHoverCard(e,m)" @mouseleave="closeHoverCard" :src="m.senderId == myInfo.id ? (myInfo?.avatar || defaultAvatar) : m.senderAvatar ?? defaultAvatar" class="avatar-chat" />
|
||||
|
||||
<img @mouseenter="(e) => handleHoverCard(e,m)" @mouseleave="closeHoverCard" :src="m.senderId == myInfo.id ? (myInfo?.avatar || defaultAvatar) :m.chatType == MESSAGE_TYPE.GROUP ? m.senderAvatar : conversationInfo.targetAvatar ?? defaultAvatar" class="avatar-chat" />
|
||||
|
||||
<div class="msg-content">
|
||||
<div class="group-sendername" v-if="m.chatType == MESSAGE_TYPE.GROUP && m.senderId != myInfo.id">{{ m.senderName }}</div>
|
||||
<div class="bubble" @contextmenu.prevent="(e) => handleRightClick(e, m)">
|
||||
<div v-if="m.type === 'Text'">{{ m.content }}</div>
|
||||
<div v-else-if="m.type === 'emoji'" class="emoji-msg">{{ m.content }}</div>
|
||||
<div v-else-if="m.type === FILE_TYPE.Image" class="image-msg-container" :style="getImageStyle(m.content)">
|
||||
<img
|
||||
class="image-msg-content"
|
||||
:src="m.isImgLoading || m.isLoading ? m.localUrl : m.content.thumb"
|
||||
alt="图片消息" @click="imagePreview(m)"
|
||||
>
|
||||
|
||||
<div v-if="m.isImgLoading || m.isError" class="image-overlay">
|
||||
<div v-if="m.isImgLoading" class="progress-box">
|
||||
<div class="circular-progress">
|
||||
<svg width="40" height="40" viewBox="0 0 40 40">
|
||||
<circle class="bg" cx="20" cy="20" r="16" />
|
||||
<circle
|
||||
class="bar"
|
||||
cx="20" cy="20" r="16"
|
||||
:style="{ strokeDashoffset: 100 - (m.progress || 0) }"
|
||||
/>
|
||||
</svg>
|
||||
<span class="pct">{{ m.progress || 0 }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<i v-if="m.isError" class="error-icon" v-html="feather.icons['alert-circle'].toSvg({width:24, height: 24})"></i>
|
||||
</div>
|
||||
</div>
|
||||
<VideoMsg v-else-if="m.type === FILE_TYPE.Video"
|
||||
:thumbnailUrl="m.localUrl ?? m.content.thumb"
|
||||
:duration="m.content.duration"
|
||||
:w="m.content.w"
|
||||
:h="m.content.h"
|
||||
:uploading="m.isImgLoading"
|
||||
:progress="+m.progress"
|
||||
@play="playHandler(m)"
|
||||
/>
|
||||
<VoiceMsg v-else-if="m.type === FILE_TYPE.Voice"
|
||||
:url="m.localUrl ?? m.content.url"
|
||||
:duration="m.content.duration"
|
||||
:isRead="true"
|
||||
:isSelf="m.senderId != myInfo.id"
|
||||
/>
|
||||
<div class="status" v-if="m.senderId == myInfo.id">
|
||||
<i v-if="m.isError" style="color: red;" v-html="feather.icons['alert-circle'].toSvg({width:18, height: 18})"></i>
|
||||
<i v-if="m.isLoading" class="loaderIcon" v-html="feather.icons['loader'].toSvg({width:18, height: 18})"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<span class="msg-time">{{ formatDate(m.timeStamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -33,16 +97,18 @@
|
||||
|
||||
<footer class="chat-footer">
|
||||
<div class="toolbar">
|
||||
<button class="tool-btn" @click="toggleEmoji" v-html="feather.icons['smile'].toSvg({width:18, height: 18})">
|
||||
<button class="tool-btn" @click="toggleEmoji" v-html="feather.icons['smile'].toSvg({width:25, height: 25})">
|
||||
</button>
|
||||
<label class="tool-btn">
|
||||
<i v-html="feather.icons['file'].toSvg({width:18, height: 18})"></i>
|
||||
<input type="file" hidden @change="handleFile" />
|
||||
<i v-html="feather.icons['file'].toSvg({width:25, height: 25})"></i>
|
||||
<input type="file" hidden @change="handleFile($event.target.files)" />
|
||||
</label>
|
||||
<button :class="['tool-btn', isRecord ? 'is-recording' : '']" @mousedown="startRecord" @mouseup="stopRecord" v-html="feather.icons[isRecord ? 'mic' : 'mic-off'].toSvg({width:25, height: 25})">
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="input"
|
||||
placeholder="请输入消息..."
|
||||
<textarea
|
||||
v-model="input"
|
||||
placeholder="请输入消息..."
|
||||
@keydown.enter.exact.prevent="sendText"
|
||||
></textarea>
|
||||
<div class="send-row">
|
||||
@@ -53,24 +119,28 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, onMounted, watch, onUnmounted } from 'vue';
|
||||
import { ref, nextTick, onMounted, watch, onUnmounted } from 'vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import defaultAvatar from '@/assets/default_avatar.png';
|
||||
import { messageService } from '@/services/message';
|
||||
import { formatDate } from '@/utils/formatDate';
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
import { generateSessionId } from '@/utils/sessionIdTools';
|
||||
import { useSignalRStore } from '@/stores/signalr';
|
||||
import { useConversationStore } from '@/stores/conversation';
|
||||
import feather from 'feather-icons';
|
||||
import { onBeforeRouteUpdate } from 'vue-router';
|
||||
import { MESSAGE_TYPE } from '@/constants/MessageType';
|
||||
import { GetLocalIso } from '@/utils/dateTool';
|
||||
import HistoryLoading from '@/components/messages/HistoryLoading.vue';
|
||||
import { useMessage } from '@/components/messages/useAlert';
|
||||
import { SYSTEM_BASE_STATUS } from '@/constants/systemBaseStatus';
|
||||
import UserHoverCard from '@/components/user/UserHoverCard.vue';
|
||||
import ContextMenu from '@/components/ContextMenu.vue';
|
||||
import { useSendMessageHandler } from './hooks/useSendMessageHandler';
|
||||
import { previewImages } from 'hevue-img-preview/v3'
|
||||
import { useMessage } from '@/components/messages/useAlert';
|
||||
import { FILE_TYPE, getMessageType } from '@/constants/fileTypeDefine';
|
||||
import { generateImageThumbnailBlob, getVideoDuration, getVideoThumbnailBlob, loadImage } from '@/utils/imageTools';
|
||||
import { ImageInfo, VideoInfo, VoiceInfo } from '@/constants/fileTypeInfo';
|
||||
import VideoMsg from '@/components/messages/VideoMsg.vue';
|
||||
import VoiceMsg from '@/components/messages/VoiceMsg.vue';
|
||||
|
||||
|
||||
const props = defineProps({
|
||||
id:{
|
||||
@@ -82,6 +152,8 @@ const props = defineProps({
|
||||
const chatStore = useChatStore();
|
||||
const signalRStore = useSignalRStore();
|
||||
const conversationStore = useConversationStore();
|
||||
const message = useMessage();
|
||||
const {sendMessage, sendFileMessage, sendTextMessage} = useSendMessageHandler();
|
||||
|
||||
const input = ref(''); // 输入框内容
|
||||
const historyRef = ref(null); // 绑定 DOM 用于滚动
|
||||
@@ -98,8 +170,95 @@ const isLoading = ref(false);
|
||||
const isFinished = ref(false);
|
||||
const hasError = ref(false);
|
||||
let observer = null;
|
||||
const isRecord = ref(false);
|
||||
let mediaRecorder = null;
|
||||
let audioChunks = []; // 用于存储录音的数据片段
|
||||
const videoUrl = ref(null);
|
||||
const videoOpen = ref(false)
|
||||
|
||||
|
||||
const getImageStyle = (content) => {
|
||||
const maxWidth = 200; // 最大宽度
|
||||
const maxHeight = 200; // 最大高度
|
||||
const minSize = 60; // 最小尺寸,防止变成一个点
|
||||
|
||||
let w = content.W || maxWidth;
|
||||
let h = content.H || maxHeight;
|
||||
|
||||
const ratio = w / h;
|
||||
|
||||
if (w > h) {
|
||||
// 横图
|
||||
w = Math.min(w, maxWidth);
|
||||
h = w / ratio;
|
||||
} else {
|
||||
// 纵图
|
||||
h = Math.min(h, maxHeight);
|
||||
w = h * ratio;
|
||||
}
|
||||
|
||||
return {
|
||||
width: `${Math.max(w, minSize)}px`,
|
||||
height: `${Math.max(h, minSize)}px`
|
||||
};
|
||||
};
|
||||
|
||||
const imagePreview = (m) => {
|
||||
const imageList = chatStore.messages
|
||||
.filter(x => x.type == 'Image')
|
||||
;
|
||||
const index = imageList.indexOf(m);
|
||||
previewImages({
|
||||
imgList: imageList.map(m => m.content.url),
|
||||
nowImgIndex: index
|
||||
});
|
||||
}
|
||||
|
||||
const startRecord = async () => {
|
||||
try{
|
||||
const stream = await navigator.mediaDevices.getUserMedia({audio:true});
|
||||
mediaRecorder = new MediaRecorder(stream);
|
||||
audioChunks = [];
|
||||
mediaRecorder.ondataavailable = e => {
|
||||
if(e.data.size > 0) audioChunks.push(e.data);
|
||||
}
|
||||
// 录音结束后的处理
|
||||
mediaRecorder.onstop = () => {
|
||||
const audioBlob = new Blob(audioChunks, { type: 'audio/mp3' });
|
||||
// 生成一个唯一的文件名:时间戳 + 随机数 + 后缀
|
||||
const fileName = `audio_${Date.now()}_${Math.floor(Math.random() * 1000)}.mp3`;
|
||||
|
||||
// 将 Blob 转换为 File 对象
|
||||
const audioFile = new File([audioBlob], fileName, {
|
||||
type: 'audio/mp3',
|
||||
lastModified: Date.now()
|
||||
});
|
||||
handleFile([audioFile]).finally(() => {
|
||||
// 停止所有轨道以关闭麦克风图标
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
});
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
isRecord.value = true;
|
||||
}catch(e){
|
||||
console.log(e)
|
||||
message.error('无法获取麦克风权限!');
|
||||
}
|
||||
}
|
||||
|
||||
const stopRecord = async () => {
|
||||
if (mediaRecorder && isRecord.value) {
|
||||
mediaRecorder.stop();
|
||||
isRecord.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const playHandler = (m) => {
|
||||
videoUrl.value = m.content.url
|
||||
videoOpen.value = true
|
||||
}
|
||||
|
||||
const loadHistoryMsg = async () => {
|
||||
// 1. 如果正在加载,或者已经彻底没数据了,才拦截
|
||||
if (isLoading.value || isFinished.value) return;
|
||||
@@ -145,30 +304,30 @@ const closeHoverCard = () => {
|
||||
const handleRightClick = (e, m) => {
|
||||
e.stopPropagation();
|
||||
const items = [
|
||||
{
|
||||
label: '复制',
|
||||
action: () => console.log('打开之前的悬浮卡片', user)
|
||||
{
|
||||
label: '复制',
|
||||
action: () => console.log('打开之前的悬浮卡片', user)
|
||||
},
|
||||
{
|
||||
label: '转发',
|
||||
action: () => console.log('进入私聊', user.id)
|
||||
{
|
||||
label: '转发',
|
||||
action: () => console.log('进入私聊', user.id)
|
||||
},
|
||||
{
|
||||
label: '多选',
|
||||
action: () => {}
|
||||
{
|
||||
label: '多选',
|
||||
action: () => {}
|
||||
},
|
||||
{
|
||||
label: '翻译',
|
||||
action: () => {}
|
||||
{
|
||||
label: '翻译',
|
||||
action: () => {}
|
||||
},
|
||||
{
|
||||
label: '引用',
|
||||
action: () => {}
|
||||
{
|
||||
label: '引用',
|
||||
action: () => {}
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
type: 'danger',
|
||||
action: () => alert('删除成功')
|
||||
{
|
||||
label: '删除',
|
||||
type: 'danger',
|
||||
action: () => alert('删除成功')
|
||||
}
|
||||
];
|
||||
menuRef.value.show(e, items);
|
||||
@@ -196,40 +355,9 @@ const scrollToBottom = async () => {
|
||||
async function sendText() {
|
||||
if (!input.value.trim()) return;
|
||||
// 根据 C# MessageBaseDto 构造的示例对象
|
||||
const msg = {
|
||||
type: "Text", // 消息类型,例如 'Text', 'Image', 'File'
|
||||
chatType: conversationInfo.value.chatType, // 'PRIVATE' 或 'GROUP'
|
||||
senderId: conversationInfo.value.userId, // 当前用户ID (对应 int)
|
||||
receiverId: conversationInfo.value.targetId, // 接收者ID (对应 int)
|
||||
content: input.value,
|
||||
timeStamp: new Date(), // 对应 DateTime
|
||||
msgId: self.crypto.randomUUID()
|
||||
};
|
||||
input.value = ''; // 清空输入框
|
||||
//设置消息为加载状态
|
||||
msg.isLoading = true;
|
||||
//将临时消息推送到消息列表(存库,方便后续重试)
|
||||
await chatStore.pushAndSortMessagesAsync([msg], generateSessionId(msg.senderId, msg.receiverId, msg.chatType == MESSAGE_TYPE.GROUP), true);
|
||||
//更新当前会话最新消息
|
||||
conversationInfo.value.lastMessage = msg.content;
|
||||
//从列表取出消息
|
||||
let updateMsg = msg;
|
||||
try{
|
||||
const res = await messageService.sendMessage(msg);
|
||||
if(res.code != SYSTEM_BASE_STATUS.SUCCESS){
|
||||
updateMsg.isError = true;
|
||||
}else{
|
||||
//发送成功将后端生成的sequenceId更新
|
||||
updateMsg = res.data;
|
||||
}
|
||||
}catch{
|
||||
updateMsg.isError = true;
|
||||
}finally{
|
||||
updateMsg.isLoading = false;
|
||||
chatStore.pushAndSortMessagesAsync([updateMsg], generateSessionId(msg.senderId, msg.receiverId, msg.chatType == MESSAGE_TYPE.GROUP), true);
|
||||
|
||||
msg.isLoading = false;
|
||||
}
|
||||
const content = input.value;
|
||||
input.value = '';
|
||||
await sendTextMessage(content, conversationInfo);
|
||||
}
|
||||
|
||||
// 通话模拟
|
||||
@@ -238,9 +366,35 @@ function startCall(type) {
|
||||
}
|
||||
|
||||
// 文件处理模拟
|
||||
function handleFile(e) {
|
||||
const file = e.target.files[0];
|
||||
if (file) console.log('选中文件:', file.name);
|
||||
async function handleFile(files) {
|
||||
const file = files[0];
|
||||
let info = {};
|
||||
let localUrl = null;
|
||||
let img = null;
|
||||
switch(getMessageType(file.type)){
|
||||
case FILE_TYPE.Image:
|
||||
localUrl = URL.createObjectURL(file);
|
||||
img = await loadImage(localUrl);
|
||||
info = new ImageInfo(file.type, '[图片]', img.width, img.height, await generateImageThumbnailBlob(await loadImage(localUrl), 200));
|
||||
break;
|
||||
|
||||
case FILE_TYPE.Video: {
|
||||
const imgBlob = await getVideoThumbnailBlob(file);
|
||||
localUrl = URL.createObjectURL(imgBlob);
|
||||
img = await loadImage(localUrl);
|
||||
|
||||
info = new VideoInfo(file.type,'[视频]', img.width, img.height, imgBlob, await getVideoDuration(file));
|
||||
break;
|
||||
}
|
||||
case FILE_TYPE.Voice: {
|
||||
localUrl = URL.createObjectURL(file);
|
||||
info = new VoiceInfo(file.type,'[语音消息]', await getVideoDuration(file));
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
await sendFileMessage(file, conversationInfo,info,localUrl);
|
||||
}
|
||||
|
||||
function toggleEmoji() {
|
||||
@@ -267,7 +421,7 @@ const initChat = async (newId) => {
|
||||
isFinished.value = false;
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -276,16 +430,16 @@ watch(
|
||||
() => {
|
||||
// 如果没有 ID,或者 Store 还没加载,返回一个安全的默认值
|
||||
if (!conversationStore.conversations.length) {
|
||||
return [props.id,null];
|
||||
return [props.id,null];
|
||||
}
|
||||
|
||||
|
||||
// 尝试找到当前会话
|
||||
const session = conversationStore.conversations.find(x => x.id == Number(props.id));
|
||||
|
||||
|
||||
// 返回 [ID, 状态],用了 ?. 即使 session 是 undefined 也只会返回 undefined,不会报错
|
||||
return [props.id, session?.isInitialized];
|
||||
},
|
||||
|
||||
|
||||
// 2. 回调逻辑
|
||||
async ([newId, isInited], [oldId, oldInited]) => {
|
||||
// 基础防守:如果 ID 无效,直接跳过
|
||||
@@ -297,20 +451,20 @@ watch(
|
||||
//if (newId !== oldId) {
|
||||
// 注意:这里只是加载本地缓存,不负责去服务器拉新
|
||||
// 真正的“拉新”逻辑交给下面的 isInited 判断
|
||||
await initChat(newId);
|
||||
await initChat(newId);
|
||||
//}
|
||||
|
||||
// 场景 B:检测到需要补洞 (isInited 变为 false)
|
||||
// 无论是“刚进页面”还是“SignalR重连”导致的,都会命中这里
|
||||
if (isInited === false) {
|
||||
console.log(`[同步触发] 会话 ${newId} 需要补洞...`);
|
||||
|
||||
|
||||
// 1. 获取当前断点(本地最新一条消息的 ID)
|
||||
const currentMax = chatStore.maxSequenceId;
|
||||
|
||||
//const currentMax = chatStore.maxSequenceId;
|
||||
|
||||
// 2. 去服务器拉取增量数据
|
||||
const msgList = await chatStore.fetchNewMsgFromServier(newId, currentMax);
|
||||
|
||||
const msgList = await chatStore.fetchNewMsgFromServier(newId);
|
||||
|
||||
const session = conversationStore.conversations.find(x => x.id == Number(newId));
|
||||
if(msgList && msgList.length > 0){
|
||||
const minSequenceId = Math.min(...msgList.map(m => m.sequenceId));
|
||||
@@ -321,7 +475,7 @@ watch(
|
||||
await chatStore.pushAndSortMessagesAsync(msgList, generateSessionId(session.userId, session.targetId, session.chatType == MESSAGE_TYPE.GROUP), true);
|
||||
}
|
||||
// 3. 如果有新消息,存入 Store
|
||||
|
||||
|
||||
|
||||
|
||||
// 4. 关键步骤:手动把状态回正!
|
||||
@@ -342,7 +496,7 @@ const initObs = async () => {
|
||||
await nextTick();
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
|
||||
|
||||
// 只有当 Loading 组件进入视口,且满足触发条件
|
||||
if (entry.isIntersecting) {
|
||||
loadHistoryMsg();
|
||||
@@ -399,10 +553,168 @@ onUnmounted(() => {
|
||||
background: #f5f5f5;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
/* 遮罩层:全屏、黑色半透明、固定定位 */
|
||||
.video-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999; /* 确保在最顶层 */
|
||||
}
|
||||
|
||||
/* 播放器弹窗主体 */
|
||||
.video-dialog {
|
||||
position: relative;
|
||||
width: 90%;
|
||||
max-width: 1000px;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
/* 顶部状态栏(包含关闭按钮) */
|
||||
.close-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
background: #1a1a1a;
|
||||
color: #eee;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
transform: scale(1.2);
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.player-wrapper {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9; /* 锁定 16:9 比例 */
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* 进场动画 */
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.fade-enter-from, .fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
border: none;
|
||||
background: none;
|
||||
display: flex;
|
||||
margin-right: 20px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tool-btn:hover {
|
||||
background: #b8b8b8;
|
||||
}
|
||||
|
||||
.tool-btn.is-recording {
|
||||
background-color: rgba(0, 196, 98, 0.1);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.image-msg {
|
||||
max-width: 100px;
|
||||
max-height: 200px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* 容器:由计算属性决定宽高 */
|
||||
.image-msg-container {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background-color: #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 图片:填满容器但不拉伸 */
|
||||
.image-msg-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover; /* 关键:裁剪而非拉伸 */
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 覆盖层 */
|
||||
.image-overlay {
|
||||
position: absolute;
|
||||
inset: 0; /* 铺满父容器 */
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 环形进度条样式 */
|
||||
.circular-progress {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.circular-progress svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.circular-progress circle {
|
||||
fill: none;
|
||||
stroke-width: 3;
|
||||
}
|
||||
.circular-progress .bg {
|
||||
stroke: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
.circular-progress .bar {
|
||||
stroke: #ffffff;
|
||||
stroke-dasharray: 100; /* 这里的 100 对应周长 */
|
||||
transition: stroke-dashoffset 0.2s;
|
||||
}
|
||||
.circular-progress .pct {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
}
|
||||
.error-icon {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
|
||||
@@ -501,6 +813,11 @@ textarea {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
|
||||
.send-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -514,4 +831,4 @@ textarea {
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useChatStore } from "@/stores/chat";
|
||||
import { generateSessionId } from "@/utils/sessionIdTools";
|
||||
import { MESSAGE_TYPE } from "@/constants/MessageType";
|
||||
import { messageService } from "@/services/message";
|
||||
import { SYSTEM_BASE_STATUS } from "@/constants/systemBaseStatus";
|
||||
import { uploadFile } from "@/services/upload/uploader";
|
||||
import { UPLOAD_STATUS } from "@/constants/uploadStatus";
|
||||
import { getMessageType } from "@/constants/fileTypeDefine";
|
||||
import { uploadService } from "@/services/upload/uploadService";
|
||||
import { getFileHash } from "@/utils/uploadTools";
|
||||
|
||||
export function useSendMessageHandler() {
|
||||
const sendMessage = async (msg) => {
|
||||
const chatStore = useChatStore();
|
||||
//设置消息为加载状态
|
||||
const msgServer = { ...msg }
|
||||
msg.isLoading = true;
|
||||
//将临时消息推送到消息列表(存库,方便后续重试)
|
||||
await chatStore.pushAndSortMessagesAsync([msg], generateSessionId(msg.senderId, msg.receiverId, msg.chatType == MESSAGE_TYPE.GROUP), true);
|
||||
//从列表取出消息
|
||||
let updateMsg = msg;
|
||||
try {
|
||||
const res = await messageService.sendMessage(msgServer);
|
||||
if (res.code != SYSTEM_BASE_STATUS.SUCCESS) {
|
||||
updateMsg.isError = true;
|
||||
} else {
|
||||
//发送成功将后端生成的sequenceId更新
|
||||
updateMsg = res.data;
|
||||
}
|
||||
} catch {
|
||||
updateMsg.isError = true;
|
||||
} finally {
|
||||
updateMsg.isLoading = false;
|
||||
chatStore.pushAndSortMessagesAsync([updateMsg], generateSessionId(msg.senderId, msg.receiverId, msg.chatType == MESSAGE_TYPE.GROUP), true);
|
||||
|
||||
msg.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
const sendTextMessage = async (text, conversationInfo) => {
|
||||
const msg = {
|
||||
type: 'Text', // 消息类型,例如 'Text', 'Image', 'File'
|
||||
chatType: conversationInfo.value.chatType, // 'PRIVATE' 或 'GROUP'
|
||||
senderId: conversationInfo.value.userId, // 当前用户ID (对应 int)
|
||||
receiverId: conversationInfo.value.targetId, // 接收者ID (对应 int)
|
||||
content: text,
|
||||
timeStamp: new Date(), // 对应 DateTime
|
||||
msgId: self.crypto.randomUUID()
|
||||
};
|
||||
//更新当前会话最新消息
|
||||
conversationInfo.value.lastMessage = msg.content;
|
||||
await sendMessage(msg);
|
||||
}
|
||||
const sendFileMessage = async (file, conversationInfo, info, localUrl) => {
|
||||
const chatStore = useChatStore();
|
||||
const msg = {
|
||||
type: getMessageType(file.type), // 消息类型,例如 'Text', 'Image', 'File'
|
||||
chatType: conversationInfo.value.chatType, // 'PRIVATE' 或 'GROUP'
|
||||
senderId: conversationInfo.value.userId, // 当前用户ID (对应 int)
|
||||
receiverId: conversationInfo.value.targetId, // 接收者ID (对应 int)
|
||||
content: '',
|
||||
timeStamp: new Date(), // 对应 DateTime
|
||||
msgId: self.crypto.randomUUID(),
|
||||
localUrl: localUrl,
|
||||
progress: 0
|
||||
};
|
||||
//更新当前会话最新消息
|
||||
conversationInfo.value.lastMessage = info.text;
|
||||
msg.isImgLoading = true;
|
||||
await chatStore.pushAndSortMessagesAsync([msg], generateSessionId(msg.senderId, msg.receiverId, msg.chatType == MESSAGE_TYPE.GROUP), true);
|
||||
|
||||
if (info.thumb) {
|
||||
const hash = await getFileHash(info.thumb);
|
||||
try {
|
||||
const { data } = await uploadService.uploadSmallFile(info.thumb, hash);
|
||||
info.thumb = data.objectName;
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
msg.isError = true;
|
||||
msg.isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
await uploadFile(file, {
|
||||
onProgress: async (e) => {
|
||||
if (!e.status) return;
|
||||
switch (e.status) {
|
||||
case UPLOAD_STATUS.MERGING:
|
||||
case UPLOAD_STATUS.UPLOADING:
|
||||
msg.progress = e.progress;
|
||||
break;
|
||||
case UPLOAD_STATUS.COMPLETE:
|
||||
msg.progress = 100;
|
||||
msg.isImgLoading = false;
|
||||
info.fileId = e.taskId;
|
||||
msg.content = JSON.stringify(info);
|
||||
await sendMessage(msg);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { sendMessage, sendFileMessage, sendTextMessage };
|
||||
}
|
||||
Reference in New Issue
Block a user