feat:聊天页面代码移植

This commit is contained in:
ctw committed 2025-11-25 16:52:10 +08:00
1 parent 6d26839aee
commit 3aaa1cb425
113 files changed
+7726 -166

No files matched your search

+4 -1
View File
@@ -60,7 +60,10 @@
"pinia-plugin-persist-uni": "^1.2.0",
"vue": "^3.4.21",
"vue-i18n": "^9.1.9",
"weixin-js-sdk": "^1.6.0"
"weixin-js-sdk": "^1.6.0",
"pinyin-pro": "^3.23.1",
"quill": "^1.3.7",
"quill-image-resize-mp": "^3.0.1"
},
"devDependencies": {
"@dcloudio/types": "^3.4.8",
+206
View File
@@ -77,6 +77,15 @@ importers:
pinia-plugin-persist-uni:
specifier: ^1.2.0
version: 1.3.1(pinia@2.3.1(typescript@4.9.5)(vue@3.5.25(typescript@4.9.5)))(vue@3.5.25(typescript@4.9.5))
pinyin-pro:
specifier: ^3.23.1
version: 3.27.0
quill:
specifier: ^1.3.7
version: 1.3.7
quill-image-resize-mp:
specifier: ^3.0.1
version: 3.0.1
vue:
specifier: ^3.4.21
version: 3.5.25(typescript@4.9.5)
@@ -1926,6 +1935,10 @@ packages:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
call-bind@1.0.8:
resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==}
engines: {node: '>= 0.4'}
call-bound@1.0.4:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'}
@@ -1970,6 +1983,10 @@ packages:
cliui@7.0.4:
resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==}
clone@2.1.2:
resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
engines: {node: '>=0.8'}
co@4.6.0:
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
@@ -2126,6 +2143,10 @@ packages:
dedent@0.7.0:
resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==}
deep-equal@1.1.2:
resolution: {integrity: sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==}
engines: {node: '>= 0.4'}
deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
@@ -2134,6 +2155,14 @@ packages:
resolution: {integrity: sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==}
engines: {node: '>= 10'}
define-data-property@1.1.4:
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
engines: {node: '>= 0.4'}
define-properties@1.2.1:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
defu@6.1.4:
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
@@ -2270,6 +2299,9 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
eventemitter3@2.0.3:
resolution: {integrity: sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==}
execa@5.1.1:
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
engines: {node: '>=10'}
@@ -2292,6 +2324,12 @@ packages:
exsolve@1.0.8:
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
fast-diff@1.1.2:
resolution: {integrity: sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==}
fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
@@ -2369,6 +2407,9 @@ packages:
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
functions-have-names@1.2.3:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
generic-names@4.0.0:
resolution: {integrity: sha512-ySFolZQfw9FoDb3ed9d80Cm9f0+r7qj+HJkWjeD9RBfpxEVTlVhol+gvaQB/78WbwYfbnNh8nWHHBSlg072y6A==}
@@ -2430,6 +2471,9 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
has-property-descriptors@1.0.2:
resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
@@ -2515,6 +2559,10 @@ packages:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
is-arguments@1.2.0:
resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==}
engines: {node: '>= 0.4'}
is-arrayish@0.2.1:
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
@@ -2526,6 +2574,10 @@ packages:
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
engines: {node: '>= 0.4'}
is-date-object@1.1.0:
resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
engines: {node: '>= 0.4'}
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -2552,6 +2604,10 @@ packages:
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
is-regex@1.2.1:
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
engines: {node: '>= 0.4'}
is-stream@2.0.1:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
@@ -2989,6 +3045,14 @@ packages:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
object-is@1.1.6:
resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==}
engines: {node: '>= 0.4'}
object-keys@1.1.1:
resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
engines: {node: '>= 0.4'}
ofetch@1.5.1:
resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==}
@@ -3028,6 +3092,9 @@ packages:
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
parchment@1.1.4:
resolution: {integrity: sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==}
parse-bmfont-ascii@1.0.6:
resolution: {integrity: sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA==}
@@ -3123,6 +3190,9 @@ packages:
typescript:
optional: true
pinyin-pro@3.27.0:
resolution: {integrity: sha512-Osdgjwe7Rm17N2paDMM47yW+jUIUH3+0RGo8QP39ZTLpTaJVDK0T58hOLaMQJbcMmAebVuK2ePunTEVEx1clNQ==}
pirates@4.0.7:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'}
@@ -3255,6 +3325,16 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
quill-delta@3.6.3:
resolution: {integrity: sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==}
engines: {node: '>=0.10'}
quill-image-resize-mp@3.0.1:
resolution: {integrity: sha512-etBtUbNH3XY21PVwfclMNGkifjWKSVXei4Dc4GWVeUyxOoympcNaV+Bk7Ocjc6Y8VlDKs2tcf3zyJ9syIFL/KA==}
quill@1.3.7:
resolution: {integrity: sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==}
range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
@@ -3286,6 +3366,10 @@ packages:
regenerator-runtime@0.14.1:
resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
regexp.prototype.flags@1.5.4:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'}
regexpu-core@6.4.0:
resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==}
engines: {node: '>=4'}
@@ -3379,6 +3463,14 @@ packages:
resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==}
engines: {node: '>= 0.8.0'}
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
set-function-name@2.0.2:
resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==}
engines: {node: '>= 0.4'}
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
@@ -6640,6 +6732,13 @@ snapshots:
es-errors: 1.3.0
function-bind: 1.1.2
call-bind@1.0.8:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
get-intrinsic: 1.3.0
set-function-length: 1.2.2
call-bound@1.0.4:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -6688,6 +6787,8 @@ snapshots:
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
clone@2.1.2: {}
co@4.6.0: {}
collect-v8-coverage@1.0.3: {}
@@ -6801,12 +6902,33 @@ snapshots:
dedent@0.7.0: {}
deep-equal@1.1.2:
dependencies:
is-arguments: 1.2.0
is-date-object: 1.1.0
is-regex: 1.2.1
object-is: 1.1.6
object-keys: 1.1.1
regexp.prototype.flags: 1.5.4
deepmerge@4.3.1: {}
default-gateway@6.0.3:
dependencies:
execa: 5.1.1
define-data-property@1.1.4:
dependencies:
es-define-property: 1.0.1
es-errors: 1.3.0
gopd: 1.2.0
define-properties@1.2.1:
dependencies:
define-data-property: 1.1.4
has-property-descriptors: 1.0.2
object-keys: 1.1.1
defu@6.1.4: {}
delayed-stream@1.0.0: {}
@@ -6926,6 +7048,8 @@ snapshots:
etag@1.8.1: {}
eventemitter3@2.0.3: {}
execa@5.1.1:
dependencies:
cross-spawn: 7.0.6
@@ -6987,6 +7111,10 @@ snapshots:
exsolve@1.0.8: {}
extend@3.0.2: {}
fast-diff@1.1.2: {}
fast-glob@3.3.3:
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -7061,6 +7189,8 @@ snapshots:
function-bind@1.1.2: {}
functions-have-names@1.2.3: {}
generic-names@4.0.0:
dependencies:
loader-utils: 3.3.1
@@ -7123,6 +7253,10 @@ snapshots:
has-flag@4.0.0: {}
has-property-descriptors@1.0.2:
dependencies:
es-define-property: 1.0.1
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
@@ -7200,6 +7334,11 @@ snapshots:
ipaddr.js@1.9.1: {}
is-arguments@1.2.0:
dependencies:
call-bound: 1.0.4
has-tostringtag: 1.0.2
is-arrayish@0.2.1: {}
is-binary-path@2.1.0:
@@ -7210,6 +7349,11 @@ snapshots:
dependencies:
hasown: 2.0.2
is-date-object@1.1.0:
dependencies:
call-bound: 1.0.4
has-tostringtag: 1.0.2
is-extglob@2.1.1: {}
is-fullwidth-code-point@3.0.0: {}
@@ -7226,6 +7370,13 @@ snapshots:
is-potential-custom-element-name@1.0.1: {}
is-regex@1.2.1:
dependencies:
call-bound: 1.0.4
gopd: 1.2.0
has-tostringtag: 1.0.2
hasown: 2.0.2
is-stream@2.0.1: {}
is-typedarray@1.0.0: {}
@@ -7868,6 +8019,13 @@ snapshots:
object-inspect@1.13.4: {}
object-is@1.1.6:
dependencies:
call-bind: 1.0.8
define-properties: 1.2.1
object-keys@1.1.1: {}
ofetch@1.5.1:
dependencies:
destr: 2.0.5
@@ -7906,6 +8064,8 @@ snapshots:
pako@1.0.11: {}
parchment@1.1.4: {}
parse-bmfont-ascii@1.0.6: {}
parse-bmfont-binary@1.0.6: {}
@@ -7986,6 +8146,8 @@ snapshots:
transitivePeerDependencies:
- '@vue/composition-api'
pinyin-pro@3.27.0: {}
pirates@4.0.7: {}
pixelmatch@4.0.2:
@@ -8115,6 +8277,25 @@ snapshots:
queue-microtask@1.2.3: {}
quill-delta@3.6.3:
dependencies:
deep-equal: 1.1.2
extend: 3.0.2
fast-diff: 1.1.2
quill-image-resize-mp@3.0.1:
dependencies:
lodash: 4.17.21
quill@1.3.7:
dependencies:
clone: 2.1.2
deep-equal: 1.1.2
eventemitter3: 2.0.3
extend: 3.0.2
parchment: 1.1.4
quill-delta: 3.6.3
range-parser@1.2.1: {}
raw-body@2.5.2:
@@ -8144,6 +8325,15 @@ snapshots:
regenerator-runtime@0.14.1: {}
regexp.prototype.flags@1.5.4:
dependencies:
call-bind: 1.0.8
define-properties: 1.2.1
es-errors: 1.3.0
get-proto: 1.0.1
gopd: 1.2.0
set-function-name: 2.0.2
regexpu-core@6.4.0:
dependencies:
regenerate: 1.4.2
@@ -8266,6 +8456,22 @@ snapshots:
transitivePeerDependencies:
- supports-color
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
es-errors: 1.3.0
function-bind: 1.1.2
get-intrinsic: 1.3.0
gopd: 1.2.0
has-property-descriptors: 1.0.2
set-function-name@2.0.2:
dependencies:
define-data-property: 1.1.4
es-errors: 1.3.0
functions-have-names: 1.2.3
has-property-descriptors: 1.0.2
setprototypeof@1.2.0: {}
shebang-command@2.0.0:
+25
View File
@@ -0,0 +1,25 @@
//设置环境(打包前修改此变量)
const ENV = "DEV";
const UNI_APP = {};
// 表情包路径
UNI_APP.EMO_URL = "/static/emoji/";
// #ifdef MP-WEIXIN
// 微信小程序的本地表情包经常莫名失效,建议将表情放到服务器中
// UNI_APP.EMO_URL = "https://www.boxim.online/emoji/";
// #endif
if (ENV == "DEV") {
UNI_APP.BASE_URL = "http://chatsever.zianxin.com";
// UNI_APP.BASE_URL = "https://www.boxim.online/api";
UNI_APP.WS_URL = "ws://chatapp.zianxin.com/im";
// UNI_APP.WS_URL = "wss://www.boxim.online/im";
// H5 走本地代理解决跨域问题
// #ifdef H5
UNI_APP.BASE_URL = "/api";
// #endif
}
if (ENV == "PROD") {
UNI_APP.BASE_URL = "https://www.boxim.online/api";
UNI_APP.WS_URL = "wss://www.boxim.online/im";
}
export default UNI_APP;
+599 -31
View File
@@ -1,47 +1,615 @@
<script setup>
<script>
import { onLaunch, onShow, onError } from '@dcloudio/uni-app';
import { ShoproInit } from './sheep';
import RecentApi from './sheep/api/member/recent';
import http from './common/request';
import * as msgType from './common/messageType';
import * as enums from './common/enums';
import * as wsApi from './common/wssocket';
import UNI_APP from '@/.env.js';
onLaunch(() => {
// 隐藏原生导航栏 使用自定义底部导航
// uni.hideTabBar({
// fail: () => {},
// });
export default {
data() {
return {
isExit: false, // 是否已退出
audioTip: null,
reconnecting: false, // 正在重连标志
privateMessagesBuffer: [],
groupMessagesBuffer: [],
};
},
methods: {
init() {
console.log('xxxc');
// 加载Shopro底层依赖
ShoproInit();
this.reconnecting = false;
this.isExit = false;
// 加载数据
this.loadStore()
.then(() => {
// 初始化websocket
this.initWebSocket();
})
.catch((e) => {
console.log(e);
this.exit();
});
},
initWebSocket() {
let loginInfo = uni.getStorageSync('loginInfo');
wsApi.connect(UNI_APP.WS_URL, loginInfo.accessToken);
wsApi.onConnect(() => {
if (this.reconnecting) {
// 重连成功
this.onReconnectWs();
} else {
// 加载离线消息
this.pullOfflineMessage();
this.configStore.setAppInit(true);
}
});
wsApi.onMessage((cmd, msgInfo) => {
if (cmd == 2) {
// 异地登录,强制下线
uni.showModal({
content: '您已在其他地方登录,将被强制下线',
showCancel: false,
});
this.exit();
} else if (cmd == 3) {
if (!this.configStore.appInit || this.chatStore.loading) {
// 如果正在拉取离线消息,先存入缓存区,等待消息拉取完成再处理,防止消息乱序
this.privateMessagesBuffer.push(msgInfo);
} else {
// 插入私聊消息
this.handlePrivateMessage(msgInfo);
}
} else if (cmd == 4) {
if (!this.configStore.appInit || this.chatStore.loading) {
// 如果正在拉取离线消息,先存入缓存区,等待消息拉取完成再处理,防止消息乱序
this.privateMessagesBuffer.push(msgInfo);
} else {
// 插入群聊消息
this.handleGroupMessage(msgInfo);
}
} else if (cmd == 5) {
// 系统消息
this.handleSystemMessage(msgInfo);
}
});
wsApi.onClose((res) => {
console.log('ws断开', res);
// 重新连接
if (!this.reconnecting) {
this.reconnectWs();
this.configStore.setAppInit(false);
}
});
},
loadStore() {
console.log(this.userStore);
// 更新用户位置
uni.getLocation({
success: (res) => {
const params = {
issueLat: String(res.latitude),
issueLong: String(res.longitude),
return this.userStore.loadUser().then(() => {
const promises = [];
promises.push(this.friendStore.loadFriend());
promises.push(this.groupStore.loadGroup());
promises.push(this.chatStore.loadChat());
promises.push(this.configStore.loadConfig());
return Promise.all(promises);
});
},
unloadStore() {
this.friendStore.clear();
this.groupStore.clear();
this.chatStore.clear();
this.configStore.clear();
this.userStore.clear();
},
pullOfflineMessage() {
this.chatStore.setLoading(true);
const promises = [];
promises.push(this.pullPrivateOfflineMessage(this.chatStore.privateMsgMaxId));
promises.push(this.pullGroupOfflineMessage(this.chatStore.groupMsgMaxId));
Promise.all(promises)
.then((messages) => {
// 处理离线消息
messages[0].forEach((m) => this.handlePrivateMessage(m));
messages[1].forEach((m) => this.handleGroupMessage(m));
// 处理缓冲区收到的实时消息
this.privateMessagesBuffer.forEach((m) => this.handlePrivateMessage(m));
this.groupMessagesBuffer.forEach((m) => this.handleGroupMessage(m));
// 清空缓冲区
this.privateMessagesBuffer = [];
this.groupMessagesBuffer = [];
// 关闭加载离线标记
this.chatStore.setLoading(false);
// 刷新会话
this.chatStore.refreshChats();
})
.catch((e) => {
console.log(e);
uni.showToast({
title: '拉取离线消息失败',
icon: 'none',
});
this.exit();
});
},
pullPrivateOfflineMessage(minId) {
return this.$http({
url: '/message/private/loadOfflineMessage?minId=' + minId,
method: 'GET',
timeout: 60000,
});
},
pullGroupOfflineMessage(minId) {
return this.$http({
url: '/message/group/loadOfflineMessage?minId=' + minId,
method: 'GET',
timeout: 60000,
});
},
handlePrivateMessage(msg) {
// 标记这条消息是不是自己发的
msg.selfSend = msg.sendId == this.userStore.userInfo.id;
// 好友id
let friendId = msg.selfSend ? msg.recvId : msg.sendId;
// 会话信息
let chatInfo = {
type: 'PRIVATE',
targetId: friendId,
};
RecentApi.updateUserLocation(params);
// 消息已读处理,清空已读数量
if (msg.type == enums.MESSAGE_TYPE.READED) {
this.chatStore.resetUnreadCount(chatInfo);
return;
}
// 消息回执处理,改消息状态为已读
if (msg.type == enums.MESSAGE_TYPE.RECEIPT) {
this.chatStore.readedMessage({
friendId: msg.sendId,
});
return;
}
// 消息撤回
if (msg.type == enums.MESSAGE_TYPE.RECALL) {
this.chatStore.recallMessage(msg, chatInfo);
return;
}
// 新增好友
if (msg.type == enums.MESSAGE_TYPE.FRIEND_NEW) {
this.friendStore.addFriend(JSON.parse(msg.content));
return;
}
// 删除好友
if (msg.type == enums.MESSAGE_TYPE.FRIEND_DEL) {
this.friendStore.removeFriend(friendId);
return;
}
// 对好友设置免打扰
if (msg.type == enums.MESSAGE_TYPE.FRIEND_DND) {
this.friendStore.setDnd(friendId, JSON.parse(msg.content));
this.chatStore.setDnd(chatInfo, JSON.parse(msg.content));
return;
}
// 消息插入
let friend = this.loadFriendInfo(friendId);
this.insertPrivateMessage(friend, msg);
},
fail: (err) => {
uni.showToast({ title: '位置信息获取失败', icon: 'none' });
insertPrivateMessage(friend, msg) {
// 单人视频信令
if (msgType.isRtcPrivate(msg.type)) {
// #ifdef MP-WEIXIN
// 小程序不支持音视频
return;
// #endif
// 被呼叫,弹出视频页面
let delayTime = 100;
if (
msg.type == enums.MESSAGE_TYPE.RTC_CALL_VOICE ||
msg.type == enums.MESSAGE_TYPE.RTC_CALL_VIDEO
) {
let mode = msg.type == enums.MESSAGE_TYPE.RTC_CALL_VIDEO ? 'video' : 'voice';
let pages = getCurrentPages();
let curPage = pages[pages.length - 1].route;
if (curPage != 'pages/chat/chat-private-video') {
const friendInfo = encodeURIComponent(JSON.stringify(friend));
uni.navigateTo({
url: `/pages/chat/chat-private-video?mode=${mode}&friend=${friendInfo}&isHost=false`,
});
delayTime = 500;
}
}
setTimeout(() => {
uni.$emit('WS_RTC_PRIVATE', msg);
}, delayTime);
return;
}
// 插入消息
if (msgType.isNormal(msg.type) || msgType.isTip(msg.type) || msgType.isAction(msg.type)) {
let chatInfo = {
type: 'PRIVATE',
targetId: friend.id,
showName: friend.nickName,
headImage: friend.headImage,
isDnd: friend.isDnd,
};
// 打开会话
this.chatStore.openChat(chatInfo);
// 插入消息
this.chatStore.insertMessage(msg, chatInfo);
// 播放提示音
if (
!friend.isDnd &&
!this.chatStore.loading &&
!msg.selfSend &&
msgType.isNormal(msg.type) &&
msg.status != enums.MESSAGE_STATUS.READED
) {
this.playAudioTip();
}
}
},
});
});
handleGroupMessage(msg) {
// 标记这条消息是不是自己发的
msg.selfSend = msg.sendId == this.userStore.userInfo.id;
let chatInfo = {
type: 'GROUP',
targetId: msg.groupId,
};
// 消息已读处理
if (msg.type == enums.MESSAGE_TYPE.READED) {
// 我已读对方的消息,清空已读数量
this.chatStore.resetUnreadCount(chatInfo);
return;
}
// 消息回执处理
if (msg.type == enums.MESSAGE_TYPE.RECEIPT) {
// 更新消息已读人数
let msgInfo = {
id: msg.id,
groupId: msg.groupId,
readedCount: msg.readedCount,
receiptOk: msg.receiptOk,
};
this.chatStore.updateMessage(msgInfo, chatInfo);
return;
}
// 消息撤回
if (msg.type == enums.MESSAGE_TYPE.RECALL) {
this.chatStore.recallMessage(msg, chatInfo);
return;
}
// 新增群
if (msg.type == enums.MESSAGE_TYPE.GROUP_NEW) {
this.groupStore.addGroup(JSON.parse(msg.content));
return;
}
// 删除群
if (msg.type == enums.MESSAGE_TYPE.GROUP_DEL) {
this.groupStore.removeGroup(msg.groupId);
return;
}
// 对群设置免打扰
if (msg.type == enums.MESSAGE_TYPE.GROUP_DND) {
this.groupStore.setDnd(msg.groupId, JSON.parse(msg.content));
this.chatStore.setDnd(chatInfo, JSON.parse(msg.content));
return;
}
// 插入消息
let group = this.loadGroupInfo(msg.groupId);
this.insertGroupMessage(group, msg);
},
handleSystemMessage(msg) {
if (msg.type == enums.MESSAGE_TYPE.USER_BANNED) {
// 用户被封禁
wsApi.close(3099);
uni.showModal({
content: '您的账号已被管理员封禁,原因:' + msg.content,
showCancel: false,
});
this.exit();
}
},
insertGroupMessage(group, msg) {
// 群视频信令
if (msgType.isRtcGroup(msg.type)) {
// #ifdef MP-WEIXIN
// 小程序不支持音视频
return;
// #endif
// 被呼叫,弹出视频页面
let delayTime = 100;
if (msg.type == enums.MESSAGE_TYPE.RTC_GROUP_SETUP) {
let pages = getCurrentPages();
let curPage = pages[pages.length - 1].route;
if (curPage != 'pages/chat/chat-group-video') {
const userInfos = encodeURIComponent(msg.content);
const inviterId = msg.sendId;
const groupId = msg.groupId;
uni.navigateTo({
url: `/pages/chat/chat-group-video?groupId=${groupId}&isHost=false
&inviterId=${inviterId}&userInfos=${userInfos}`,
});
delayTime = 500;
}
}
// 消息转发到chat-group-video页面进行处理
setTimeout(() => {
uni.$emit('WS_RTC_GROUP', msg);
}, delayTime);
return;
}
// 插入消息
if (msgType.isNormal(msg.type) || msgType.isTip(msg.type) || msgType.isAction(msg.type)) {
let chatInfo = {
type: 'GROUP',
targetId: group.id,
showName: group.showGroupName,
headImage: group.headImageThumb,
isDnd: group.isDnd,
};
// 打开会话
this.chatStore.openChat(chatInfo);
// 插入消息
this.chatStore.insertMessage(msg, chatInfo);
// 播放提示音
if (
!group.isDnd &&
!this.chatStore.loading &&
!msg.selfSend &&
msgType.isNormal(msg.type) &&
msg.status != enums.MESSAGE_STATUS.READED
) {
this.playAudioTip();
}
}
},
loadFriendInfo(id, callback) {
let friend = this.friendStore.findFriend(id);
if (!friend) {
console.log('未知用户:', id);
friend = {
id: id,
showNickName: '未知用户',
headImage: '',
};
}
return friend;
},
loadGroupInfo(id) {
let group = this.groupStore.findGroup(id);
if (!group) {
group = {
id: id,
showGroupName: '未知群聊',
headImageThumb: '',
};
}
return group;
},
exit() {
console.log('exit');
this.isExit = true;
wsApi.close(3099);
uni.removeStorageSync('loginInfo');
uni.reLaunch({
url: '/pages/login/login',
});
this.unloadStore();
},
playAudioTip() {
// 音频播放无法成功
// this.audioTip = uni.createInnerAudioContext();
// this.audioTip.src = "/static/audio/tip.wav";
// this.audioTip.play();
},
refreshToken(loginInfo) {
return new Promise((resolve, reject) => {
if (!loginInfo || !loginInfo.refreshToken) {
reject();
return;
}
http({
url: '/refreshToken',
method: 'PUT',
header: {
refreshToken: loginInfo.refreshToken,
},
})
.then((newLoginInfo) => {
uni.setStorageSync('loginInfo', newLoginInfo);
resolve();
})
.catch((e) => {
reject(e);
});
});
},
reconnectWs() {
// 已退出则不再重连
if (this.isExit) {
return;
}
// 记录标志
this.reconnecting = true;
// 重新加载一次个人信息,目的是为了保证网络已经正常且token有效
this.userStore
.loadUser()
.then((userInfo) => {
uni.showToast({
title: '连接已断开,尝试重新连接...',
icon: 'none',
});
// 重新连接
let loginInfo = uni.getStorageSync('loginInfo');
wsApi.reconnect(UNI_APP.WS_URL, loginInfo.accessToken);
})
.catch(() => {
// 5s后重试
setTimeout(() => {
this.reconnectWs();
}, 5000);
});
},
onReconnectWs() {
this.reconnecting = false;
// 重新加载好友和群聊
const promises = [];
promises.push(this.friendStore.loadFriend());
promises.push(this.groupStore.loadGroup());
Promise.all(promises)
.then(() => {
uni.showToast({
title: '已重新连接',
icon: 'none',
});
// 加载离线消息
this.pullPrivateOfflineMessage(this.chatStore.privateMsgMaxId);
this.pullGroupOfflineMessage(this.chatStore.groupMsgMaxId);
this.configStore.setAppInit(true);
})
.catch((e) => {
console.log(e);
this.exit();
});
},
closeSplashscreen(delay) {
// #ifdef APP-PLUS
// 关闭开机动画
setTimeout(() => {
plus.navigator.closeSplashscreen();
}, delay);
// #endif
},
},
onLaunch() {
// 隐藏原生导航栏 使用自定义底部导航
// uni.hideTabBar({
// fail: () => {},
// });
// 加载Shopro底层依赖
ShoproInit();
// 更新用户位置
uni.getLocation({
success: (res) => {
const params = {
issueLat: String(res.latitude),
issueLong: String(res.longitude),
};
RecentApi.updateUserLocation(params);
},
fail: (err) => {
uni.showToast({ title: '位置信息获取失败', icon: 'none' });
},
});
onShow(() => {
// #ifdef APP-PLUS
// 获取urlSchemes参数
const args = plus.runtime.arguments;
if (args) {
}
this.$mountStore();
// 延迟1s,避免用户看到页面跳转
this.closeSplashscreen(1000);
uni.setStorageSync('loginInfo', {
accessToken:
'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiIyIiwiZXhwIjoxNzY0MDU5ODk2LCJpbmZvIjoie1wibmlja05hbWVcIjpcInd3d1wiLFwidGVybWluYWxcIjoxLFwidXNlcklkXCI6MixcInVzZXJOYW1lXCI6XCJ3d3dcIn0ifQ.xBfQJg6FFHQGzkK_IUGZUjVN8g5tex3VtWsTr1gGjzg',
accessTokenExpiresIn: 1800,
refreshToken:
'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiIyIiwiZXhwIjoxNzY0NjYyODk2LCJpbmZvIjoie1wibmlja05hbWVcIjpcInd3d1wiLFwidGVybWluYWxcIjoxLFwidXNlcklkXCI6MixcInVzZXJOYW1lXCI6XCJ3d3dcIn0ifQ.o87SSm-pWBkPKROeNBP45VmpBVSdw__Kps4YfiJPZwg',
refreshTokenExpiresIn: 604800,
});
// 登录状态校验
let loginInfo = uni.getStorageSync('loginInfo');
this.refreshToken(loginInfo)
.then(() => {
// #ifdef H5
// 跳转到聊天页
uni.switchTab({
url: '/pages/message/message',
});
// #endif
// 初始化
this.init();
this.closeSplashscreen(0);
})
.catch(() => {
// 跳转到登录页
// uni.navigateTo({
// url: '/pages/login/login',
// });
});
},
onShow() {
// #ifdef APP-PLUS
// 获取urlSchemes参数
const args = plus.runtime.arguments;
if (args) {
}
// 获取剪贴板
uni.getClipboardData({
success: (res) => {},
});
// #endif
});
// 获取剪贴板
uni.getClipboardData({
success: (res) => {},
});
// #endif
},
};
</script>
<style lang="scss">
/* @import '@/sheep/scss/index.scss'; */
@import '@/im.scss';
@import url('./static/icon/iconfont.css');
// #ifdef H5
uni-page-head {
display: none; // h5浏览器本身就有标题
}
// #endif
.tab-page {
position: relative;
display: flex;
flex-direction: column;
// #ifdef H5
height: calc(100vh - 50px - $im-nav-bar-height); // h5平台100vh是包含了底部高度,需要减去
top: $im-nav-bar-height;
// #endif
// #ifdef APP-PLUS
height: calc(
100vh - var(--status-bar-height) - $im-nav-bar-height
); // app平台还要减去顶部手机状态栏高度
top: calc($im-nav-bar-height + var(--status-bar-height));
// #endif
// #ifdef MP-WEIXIN
height: calc(100vh - $im-nav-bar-height);
top: $im-nav-bar-height;
// #endif
color: $im-text-color;
background: $im-bg-linear;
font-size: $im-font-size;
font-family: $font-family;
}
.page {
position: relative;
// #ifdef H5
height: calc(100vh - $im-nav-bar-height); // h5平台100vh是包含了底部高度,需要减去
top: $im-nav-bar-height;
// #endif
// #ifdef APP-PLUS
height: calc(
100vh - var(--status-bar-height) - $im-nav-bar-height
); // app平台还要减去顶部手机状态栏高度
top: calc($im-nav-bar-height + var(--status-bar-height));
// #endif
// #ifdef MP-WEIXIN
height: calc(100vh - $im-nav-bar-height);
top: $im-nav-bar-height;
// #endif
color: $im-text-color;
background: $im-bg-linear;
font-size: $im-font-size;
font-family: $font-family;
}
</style>
+66
View File
@@ -0,0 +1,66 @@
let toTimeText = (timeStamp, simple) => {
var dateTime = new Date(timeStamp)
var currentTime = Date.parse(new Date()); //当前时间
var timeDiff = currentTime - dateTime; //与当前时间误差
var timeText = '';
if (timeDiff <= 60000) { //一分钟内
timeText = '刚刚';
} else if (timeDiff > 60000 && timeDiff < 3600000) {
//1小时内
timeText = Math.floor(timeDiff / 60000) + '分钟前';
} else if (timeDiff >= 3600000 && timeDiff < 86400000 && !isYestday(dateTime)) {
//今日
timeText = formatDateTime(dateTime).substr(11, 5);
} else if (isYestday(dateTime)) {
//昨天
timeText = '昨天' + formatDateTime(dateTime).substr(11, 5);
} else if (isYear(dateTime)) {
//今年
timeText = formatDateTime(dateTime).substr(5, simple ? 5 : 14);
} else {
//不属于今年
timeText = formatDateTime(dateTime);
if (simple) {
timeText = timeText.substr(2, 8);
}
}
return timeText;
}
let isYestday = (date) => {
var yesterday = new Date(new Date() - 1000 * 60 * 60 * 24);
return yesterday.getYear() === date.getYear() &&
yesterday.getMonth() === date.getMonth() &&
yesterday.getDate() === date.getDate();
}
let isYear = (date) => {
return date.getYear() === new Date().getYear();
}
let formatDateTime = (date) => {
if (date === '' || !date) {
return ''
}
var dateObject = new Date(date)
var y = dateObject.getFullYear()
var m = dateObject.getMonth() + 1
m = m < 10 ? ('0' + m) : m
var d = dateObject.getDate()
d = d < 10 ? ('0' + d) : d
var h = dateObject.getHours()
h = h < 10 ? ('0' + h) : h
var minute = dateObject.getMinutes()
minute = minute < 10 ? ('0' + minute) : minute
var second = dateObject.getSeconds()
second = second < 10 ? ('0' + second) : second
return y + '/' + m + '/' + d + ' ' + h + ':' + minute + ':' + second
}
export {
toTimeText,
isYestday,
isYear,
formatDateTime
}
+43
View File
@@ -0,0 +1,43 @@
import UNI_APP from '@/.env.js'
const emoTextList = ['憨笑', '媚眼', '开心', '坏笑', '可怜', '爱心', '笑哭', '拍手', '惊喜', '打气',
'大哭', '流泪', '饥饿', '难受', '健身', '示爱', '色色', '眨眼', '暴怒', '惊恐',
'思考', '头晕', '大吐', '酷笑', '翻滚', '享受', '鼻涕', '快乐', '雀跃', '微笑',
'贪婪', '红心', '粉心', '星星', '大火', '眼睛', '音符', "叹号", "问号", "绿叶",
"燃烧", "喇叭", "警告", "信封", "房子", "礼物", "点赞", "举手", "拍手", "点头",
"摇头", "偷瞄", "庆祝", "疾跑", "打滚", "惊吓", "起跳"
];
const regex = /\#[\u4E00-\u9FA5]{1,3}\;/gi;
let containEmoji = (content) => {
return regex.test(content)
}
let transform = (content, extClass) => {
return content.replace(regex, (emoText)=>{
// 将匹配结果替换表情图片
let word = emoText.replace(/\#|\;/gi, '');
let idx = emoTextList.indexOf(word);
if (idx == -1) {
return emoText;
}
let path = textToPath(emoText);
let img = `<img src="${path}" class="${extClass}"/>`;
return img;
});
}
let textToPath = (emoText) => {
let word = emoText.replace(/\#|\;/gi, '');
let idx = emoTextList.indexOf(word);
return UNI_APP.EMO_URL + idx + ".gif";
}
export default {
containEmoji,
emoTextList,
transform,
textToPath
}
+70
View File
@@ -0,0 +1,70 @@
const MESSAGE_TYPE = {
TEXT: 0,
IMAGE: 1,
FILE: 2,
AUDIO: 3,
VIDEO: 4,
RECALL: 10,
READED: 11,
RECEIPT: 12,
TIP_TIME: 20,
TIP_TEXT: 21,
LOADING: 30,
ACT_RT_VOICE: 40,
ACT_RT_VIDEO: 41,
USER_BANNED: 50,
FRIEND_NEW: 80,
FRIEND_DEL: 81,
FRIEND_DND: 82,
GROUP_NEW: 90,
GROUP_DEL: 91,
GROUP_DND: 92,
RTC_CALL_VOICE: 100,
RTC_CALL_VIDEO: 101,
RTC_ACCEPT: 102,
RTC_REJECT: 103,
RTC_CANCEL: 104,
RTC_FAILED: 105,
RTC_HANDUP: 106,
RTC_CANDIDATE: 107,
RTC_GROUP_SETUP: 200,
RTC_GROUP_ACCEPT: 201,
RTC_GROUP_REJECT: 202,
RTC_GROUP_FAILED: 203,
RTC_GROUP_CANCEL: 204,
RTC_GROUP_QUIT: 205,
RTC_GROUP_INVITE: 206,
RTC_GROUP_JOIN: 207,
RTC_GROUP_OFFER: 208,
RTC_GROUP_ANSWER: 209,
RTC_GROUP_CANDIDATE: 210,
RTC_GROUP_DEVICE: 211
}
const USER_STATE = {
OFFLINE: 0,
FREE: 1,
BUSY: 2
}
const TERMINAL_TYPE = {
WEB: 0,
APP: 1
}
const MESSAGE_STATUS = {
FAILED: -2, // 发送失败
SENDING: -1, // 发送中(消息没到服务器)
PENDING: 0, // 未送达(消息已到服务器,但对方没收到)
DELIVERED: 1, // 已送达(对方已收到,但是未读消息)
RECALL: 2, // 已撤回
READED: 3, // 消息已读
}
export {
MESSAGE_TYPE,
USER_STATE,
TERMINAL_TYPE,
MESSAGE_STATUS
}
+40
View File
@@ -0,0 +1,40 @@
// 是否普通消息
let isNormal = function (type) {
return type >= 0 && type < 10;
}
// 是否状态消息
let isStatus = function (type) {
return type >= 10 && type < 20;
}
// 是否提示消息
let isTip = function (type) {
return type >= 20 && type < 30;
}
// 操作交互类消息
let isAction = function (type) {
return type >= 40 && type < 50;
}
// 单人通话信令
let isRtcPrivate = function (type) {
return type >= 100 && type < 200;
}
// 多人通话信令
let isRtcGroup = function (type) {
return type >= 200 && type < 300;
}
export {
isNormal,
isStatus,
isTip,
isAction,
isRtcPrivate,
isRtcGroup
}
+67
View File
@@ -0,0 +1,67 @@
import UNI_APP from '@/.env.js';
const rc = uni.getRecorderManager();
// 录音开始时间
let startTime = null;
let checkIsEnable = ()=>{
return true;
}
let start = () => {
return new Promise((resolve, reject) => {
rc.onStart(() => {
startTime = new Date();
resolve()
});
rc.onError((e) => {
console.log(e);
reject(e)
})
rc.start({
format: 'mp3' // 录音格式,可选值:aac/mp3
});
})
}
let close = () => {
rc.stop();
}
let upload = () => {
return new Promise((resolve, reject) => {
rc.onStop((wavFile, a, b) => {
uni.uploadFile({
url: UNI_APP.BASE_URL + '/file/upload',
header: {
accessToken: uni.getStorageSync("loginInfo").accessToken
},
filePath: wavFile.tempFilePath,
name: 'file',
success: (res) => {
let r = JSON.parse(res.data);
if (r.code != 200) {
reject(r.message);
} else {
const duration = (new Date().getTime() - startTime.getTime()) /
1000
const data = {
duration: Math.round(duration),
url: r.data
}
resolve(data);
}
},
fail: (e) => {
reject(e);
}
})
});
})
}
export {
checkIsEnable,
start,
close,
upload
}
+92
View File
@@ -0,0 +1,92 @@
import UNI_APP from '@/.env.js';
let rc = null;
let duration = 0;
let chunks = [];
let stream = null;
let startTime = null;
let checkIsEnable = () => {
if (origin.indexOf('https') === -1 && origin.indexOf('localhost') === -1 &&
origin.indexOf('127.0.0.1') === -1) {
uni.showToast({
title: '请在https环境中使用录音功能',
icon: 'error'
})
return false;
}
if (!navigator.mediaDevices || !window.MediaRecorder) {
uni.showToast({
title: '当前浏览器不支持录音',
icon: 'error'
})
return false;
}
return true;
}
let start = () => {
return navigator.mediaDevices.getUserMedia({ audio: true }).then(audioStream => {
startTime = new Date().getTime();
chunks = [];
stream = audioStream;
rc = new MediaRecorder(stream)
rc.start()
})
}
let close = () => {
stream.getTracks().forEach((track) => {
track.stop()
})
rc.stop()
}
let upload = () => {
return new Promise((resolve, reject) => {
rc.ondataavailable = (e) => {
chunks.push(e.data)
}
rc.onstop = () => {
if(!chunks[0].size){
chunks = [];
return;
}
duration = (new Date().getTime() - startTime) / 1000;
const newbolb = new Blob(chunks, { 'type': 'audio/mpeg' });
const name = new Date().getDate() + '.mp3';
const file = new File([newbolb], name)
uni.uploadFile({
url: UNI_APP.BASE_URL + '/file/upload',
header: {
accessToken: uni.getStorageSync("loginInfo").accessToken
},
file: file,
name: 'file',
success: (res) => {
let r = JSON.parse(res.data);
if (r.code != 200) {
console.log(res)
reject(r.message);
} else {
const data = {
duration: parseInt(duration),
url: r.data
}
resolve(data);
}
},
fail: (e) => {
reject(e);
}
})
}
})
}
export {
checkIsEnable,
start,
close,
upload
}
+92
View File
@@ -0,0 +1,92 @@
import UNI_APP from '@/.env.js';
// 请求队列
let requestList = [];
// 是否正在刷新中
let isRefreshToken = false;
const request = (options) => {
const header = options.header || {};
const loginInfo = uni.getStorageSync('loginInfo');
if (loginInfo) {
header.accessToken = loginInfo.accessToken;
}
return new Promise(function (resolve, reject) {
uni.request({
url: UNI_APP.BASE_URL + options.url,
// url: UNI_APP.BASE_URL + options.url,
method: options.method || 'GET',
header: header,
data: options.data || {},
timeout: options.timeout || 3000,
async success(res) {
if (res.data.code == 200) {
return resolve(res.data.data);
} else if (res.data.code == 400) {
getApp().$vm.exit();
} else if (res.data.code == 401) {
console.log('token失效,尝试重新获取');
if (isRefreshToken) {
// 正在刷新token,把其他请求存起来
requestList.push(() => {
resolve(request(options));
});
return;
}
isRefreshToken = true;
// 发送请求, 进行刷新token操作, 获取新的token
const res = await reqRefreshToken(loginInfo);
if (!res || res.data.code != 200) {
requestList = [];
isRefreshToken = false;
console.log('刷新token失败');
getApp().$vm.exit();
return;
}
let newInfo = res.data.data;
uni.setStorageSync('loginInfo', newInfo);
requestList.forEach((cb) => cb());
requestList = [];
isRefreshToken = false;
// 重新发送刚才的请求
return resolve(request(options));
} else {
uni.showToast({
icon: 'none',
title: res.data.message,
duration: 1500,
});
return reject(res.data);
}
},
fail(error) {
uni.showToast({
title: '网络似乎有点不给力哟',
icon: 'none',
duration: 1500,
});
return reject(error);
},
});
});
};
const reqRefreshToken = (loginInfo) => {
return new Promise(function (resolve, reject) {
uni.request({
method: 'PUT',
url: UNI_APP.BASE_URL + '/refreshToken',
header: {
refreshToken: loginInfo.refreshToken,
},
success: (res) => {
resolve(res);
},
fail: (res) => {
reject(res);
},
});
});
};
export default request;
+14
View File
@@ -0,0 +1,14 @@
let html2Escape = (strText) => {
return strText.replace(/[<>&"]/g, function(c) {
return {
'<': '&lt;',
'>': '&gt;',
'&': '&amp;',
'"': '&quot;'
} [c];
});
}
export default {
html2Escape
}
+23
View File
@@ -0,0 +1,23 @@
// 使用正则表达式匹配更广泛的URL格式(此正则由deepseek生成)
const regex = /(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|]|\bwww\.[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/ig;
let containUrl = (content) => {
return regex.test(content)
}
let replaceURLWithHTMLLinks = (content, color) => {
return content.replace(regex, (url) => {
// 如果URL不以http(s)://开头,则添加http://前缀
if (!url.startsWith("http")) {
url = "http://" + url;
}
return `<a href="${url}" target="_blank" style="color: ${color};text-decoration: underline;">${url}</a>`;
});
}
export default {
containUrl,
replaceURLWithHTMLLinks
}
+154
View File
@@ -0,0 +1,154 @@
let accessToken = "";
let messageCallBack = null;
let closeCallBack = null;
let connectCallBack = null;
let isConnect = false; //连接标识 避免重复连接
let rec = null;
let lastConnectTime = new Date(); // 最后一次连接时间
let socketTask = null;
let connect = (wsurl, token) => {
accessToken = token;
if (isConnect) {
return;
}
lastConnectTime = new Date();
socketTask = uni.connectSocket({
url: wsurl,
success: (res) => {},
fail: (e) => {
console.log(e);
console.log("WebSocket连接失败,10s后重连");
setTimeout(() => {
connect();
}, 10000)
}
});
socketTask.onOpen((res) => {
console.log("WebSocket连接成功");
isConnect = true;
// 发送登录命令
let loginInfo = {
cmd: 0,
data: {
accessToken: accessToken
}
};
socketTask.send({
data: JSON.stringify(loginInfo)
});
})
socketTask.onMessage((res) => {
let sendInfo = JSON.parse(res.data)
if (sendInfo.cmd == 0) {
heartCheck.start()
connectCallBack && connectCallBack();
console.log('WebSocket登录成功')
} else if (sendInfo.cmd == 1) {
// 重新开启心跳定时
heartCheck.reset();
} else {
// 其他消息转发出去
console.log("接收到消息", sendInfo);
messageCallBack && messageCallBack(sendInfo.cmd, sendInfo.data)
}
})
socketTask.onClose((res) => {
console.log('WebSocket连接关闭')
isConnect = false;
closeCallBack && closeCallBack(res);
})
socketTask.onError(e => {
console.log("ws错误:", e)
close(1006);
isConnect = false;
closeCallBack && closeCallBack(e);
})
}
//定义重连函数
let reconnect = (wsurl, accessToken) => {
console.log("尝试重新连接");
if (isConnect) {
return;
}
// 延迟10秒重连 避免过多次过频繁请求重连
let timeDiff = new Date().getTime() - lastConnectTime.getTime()
let delay = timeDiff < 10000 ? 10000 - timeDiff : 0;
rec && clearTimeout(rec);
rec = setTimeout(function() {
connect(wsurl, accessToken);
}, delay);
};
//设置关闭连接
let close = (code) => {
if (!isConnect) {
return;
}
socketTask.close({
code: code,
complete: (res) => {
isConnect = false;
console.log("关闭websocket连接");
},
fail: (e) => {
console.log("关闭websocket连接失败", e);
}
});
};
// 心跳设置
let heartCheck = {
timeout: 20000, //每段时间发送一次心跳包 这里设置为20s
timeoutObj: null, //延时发送消息对象(启动心跳新建这个对象,收到消息后重置对象)
start: function() {
if (isConnect) {
console.log('发送WebSocket心跳')
let heartBeat = {
cmd: 1,
data: {}
};
sendMessage(JSON.stringify(heartBeat))
}
},
reset: function() {
clearTimeout(this.timeoutObj);
this.timeoutObj = setTimeout(() => heartCheck.start(), this.timeout);
}
};
let sendMessage = (message) => {
socketTask.send({ data: message })
}
let onConnect = (callback) => {
connectCallBack = callback;
}
let onMessage = (callback) => {
messageCallBack = callback;
}
let onClose = (callback) => {
closeCallBack = callback;
}
// 将方法暴露出去
export {
connect,
reconnect,
close,
sendMessage,
onConnect,
onMessage,
onClose
}
+64
View File
@@ -0,0 +1,64 @@
<template>
<view class="arrow-bar">
<text class="icon iconfont" :class="icon" :style="{color: textColor}"></text>
<text class="title">{{ title }}</text>
<uni-icons class="arrow" type="right" size="16"></uni-icons>
</view>
</template>
<script>
export default {
name: "arrow-bar",
props: {
title: {
type: String,
required: true
},
icon: {
type: String,
default: ''
}
},
data() {
return {
colors: ["#5daa31", "#c7515a", "#e03697", "#85029b",
"#c9b455", "#326eb6"]
}
},
computed:{
textColor() {
let hash = 0;
for (var i = 0; i < this.title.length; i++) {
hash += this.title.charCodeAt(i);
}
return this.colors[hash % this.colors.length];
}
}
}
</script>
<style lang="scss" scoped>
.arrow-bar {
width: 100%;
height: 90rpx;
font-size: $im-font-size;
color: $im-text-color;
margin-top: 5rpx;
background-color: white;
line-height: 90rpx;
display: flex;
.icon {
margin-left: 40rpx;
}
.title {
flex: 1;
margin-left: 10rpx;
}
.arrow {
margin-right: 40rpx;
}
}
</style>
+17
View File
@@ -0,0 +1,17 @@
<template>
<view class="bar-group">
<slot></slot>
</view>
</template>
<script>
export default {
name: "bar-group"
}
</script>
<style lang="scss" scoped>
.bar-group {
margin: 20rpx 0;
}
</style>
+68
View File
@@ -0,0 +1,68 @@
<template>
<view class="btn-bar" :style="style">
<text v-if="icon" class="icon iconfont" :class="icon"></text>
<text class="title">{{ title }}</text>
</view>
</template>
<script>
export default {
name: "btn-bar",
props: {
title: {
type: String,
required: true
},
icon: {
type: String,
required: false
},
type: {
type: String,
default: "normal"
},
color: {
type: String,
default: "#000"
}
},
computed: {
style() {
let color = "#000";
switch (this.type) {
case 'danger':
color = "#f14747";
break;
case 'primary':
color = "#35567f";
break;
}
return `color: ${color};`
}
}
}
</script>
<style lang="scss" scoped>
.btn-bar {
width: 100%;
height: 100rpx;
margin-top: 5rpx;
background-color: white;
line-height: 100rpx;
text-align: center;
display: flex;
justify-content: center;
.icon {
font-size: 40rpx;
font-weight: 600;
margin-right: 10rpx;
}
.title {
font-size: 32rpx;
font-weight: 600;
}
}
</style>
+61
View File
@@ -0,0 +1,61 @@
<template>
<view class="switch-bar">
<text class="title">{{ title }}</text>
<switch class="switch" :checked="checked" color="#18bc37" @change="onChange"></switch>
</view>
</template>
<script>
export default {
name: "switch-bar",
props: {
title: {
type: String,
required: true
},
checked: {
type: Boolean,
default: false
}
},
data() {
return {
value: this.checked
}
},
methods: {
onChange(e) {
this.value = true;
setTimeout(() => {
this.value = false;
}, 100)
//this.value = false;
this.$emit('change', e);
}
}
}
</script>
<style lang="scss" scoped>
.switch-bar {
width: 100%;
height: 100rpx;
font-size: 34rpx;
color: black;
margin-top: 5rpx;
background-color: white;
line-height: 100rpx;
display: flex;
.title {
flex: 1;
margin-left: 40rpx;
}
.switch {
margin-right: 40rpx;
}
}
</style>
+173
View File
@@ -0,0 +1,173 @@
<template>
<uni-popup ref="popup" type="bottom" @change="onChange">
<view class="chat-at-box">
<view class="chat-at-top">
<view class="chat-at-tip"> 选择要提醒的人</view>
<button class="chat-at-btn" type="warn" size="mini" @click="onClean()">清空 </button>
<button class="chat-at-btn" type="primary" size="mini" @click="onOk()">确定({{ atUserIds.length }})
</button>
</view>
<scroll-view v-show="atUserIds.length > 0" scroll-x="true" scroll-left="120">
<view class="at-user-items">
<view v-for="m in checkedMembers" class="at-user-item" :key="m.userId">
<head-image :name="m.showNickName" :url="m.headImage" size="mini"></head-image>
</view>
</view>
</scroll-view>
<view class="search-bar">
<uni-search-bar v-model="searchText" cancelButton="none" radius="100" placeholder="搜索"></uni-search-bar>
</view>
<view class="member-items">
<virtual-scroller :items="memberItems">
<template v-slot="{ item }">
<view class="member-item" @click="onSwitchChecked(item)">
<head-image :name="item.showNickName" :online="item.online" :url="item.headImage"
size="small"></head-image>
<view class="member-name">{{ item.showNickName }}</view>
<radio :checked="item.checked" :disabled="item.locked"
@click.stop="onSwitchChecked(item)" />
</view>
</template>
</virtual-scroller>
</view>
</view>
</uni-popup>
</template>
<script>
export default {
name: "chat-at-box",
props: {
ownerId: {
type: Number,
},
members: {
type: Array
}
},
data() {
return {
searchText: "",
showMembers: []
};
},
methods: {
init(atUserIds) {
this.showMembers = [];
let userId = this.userStore.userInfo.id;
if (this.ownerId == userId) {
this.showMembers.push({
userId: -1,
showNickName: "全体成员"
})
}
this.members.forEach((m) => {
if (!m.quit && m.userId != userId) {
m.checked = atUserIds.indexOf(m.userId) >= 0;
this.showMembers.push(m);
}
});
},
open() {
this.$refs.popup.open();
},
onSwitchChecked(member) {
member.checked = !member.checked;
},
onClean() {
this.showMembers.forEach((m) => {
m.checked = false;
})
},
onOk() {
this.$refs.popup.close();
},
onChange(e) {
if (!e.show) {
this.$emit("complete", this.atUserIds)
}
}
},
computed: {
atUserIds() {
return this.showMembers.filter(m => m.checked).map(m => m.userId);
},
checkedMembers() {
return this.showMembers.filter(m => m.checked);
},
memberItems() {
return this.showMembers.filter(m => m.showNickName.includes(this.searchText));
}
}
}
</script>
<style lang="scss" scoped>
.chat-at-box {
position: relative;
display: flex;
flex-direction: column;
background-color: white;
padding: 10rpx;
//border-radius: 15rpx;
.chat-at-top {
display: flex;
align-items: center;
height: 70rpx;
padding: 10rpx;
.chat-at-tip {
flex: 1;
}
.chat-at-btn {
margin-left: 10rpx;
}
}
.at-user-items {
display: flex;
align-items: center;
height: 90rpx;
.at-user-item {
padding: 3rpx;
}
}
.member-items {
position: relative;
flex: 1;
overflow: hidden;
.member-item {
height: 110rpx;
display: flex;
position: relative;
padding: 0 30rpx;
align-items: center;
background-color: white;
white-space: nowrap;
margin-bottom: 1px;
&:hover {
background-color: $im-bg-active;
}
.member-name {
flex: 1;
padding-left: 20rpx;
font-size: $im-font-size;
line-height: 60rpx;
white-space: nowrap;
overflow: hidden;
}
}
}
}
</style>
@@ -0,0 +1,133 @@
<template>
<uni-popup ref="popup" type="bottom">
<view class="chat-group-readed">
<view class="uni-padding-wrap uni-common-mt">
<uni-segmented-control :current="current" :values="items" style-type="button"
@clickItem="onClickItem" />
</view>
<view class="content">
<view v-if="current === 0">
<virtual-scroller :items="readedMembers">
<template v-slot="{ item }">
<view class="member-item">
<head-image :name="item.showNickName" :online="item.online" :url="item.headImage"
:size="90"></head-image>
<view class="member-name">{{ item.showNickName }}</view>
</view>
</template>
</virtual-scroller>
</view>
<view v-if="current === 1">
<virtual-scroller :items="unreadMembers">
<template v-slot="{ item }">
<view class="member-item">
<head-image :name="item.showNickName" :online="item.online" :url="item.headImage"
:size="90"></head-image>
<view class="member-name">{{ item.showNickName }}</view>
</view>
</template>
</virtual-scroller>
</view>
</view>
</view>
</uni-popup>
</template>
<script>
export default {
name: "chat-group-readed",
data() {
return {
items: ['已读', '未读'],
current: 0,
readedMembers: [],
unreadMembers: []
};
},
props: {
msgInfo: {
type: Object,
required: true
},
groupMembers: {
type: Array
}
},
methods: {
open() {
this.$refs.popup.open();
this.loadReadedUser();
},
loadReadedUser() {
this.readedMembers = [];
this.unreadMembers = [];
this.$http({
url: `/message/group/findReadedUsers?groupId=${this.msgInfo.groupId}&messageId=${this.msgInfo.id}`,
method: 'Get'
}).then(userIds => {
this.groupMembers.forEach(member => {
// 发送者和已退群的不显示
if (member.userId == this.msgInfo.sendId || member.quit) {
return;
}
// 区分已读还是未读
if (userIds.find(userId => member.userId == userId)) {
this.readedMembers.push(member);
} else {
this.unreadMembers.push(member);
}
})
this.items[0] = `已读(${this.readedMembers.length})`;
this.items[1] = `未读(${this.unreadMembers.length})`;
let chatInfo = {
type: 'GROUP',
targetId: this.msgInfo.groupId
}
let msgInfo = {
id: this.msgInfo.id,
groupId: this.msgInfo.groupId,
readedCount: this.readedMembers.length
}
// 更新已读人数
this.chatStore.updateMessage(msgInfo, chatInfo)
})
},
onClickItem(e) {
this.current = e.currentIndex;
}
}
}
</script>
<style lang="scss" scoped>
.chat-group-readed {
position: relative;
display: flex;
flex-direction: column;
background-color: white;
padding: 10rpx;
.member-item {
height: 120rpx;
display: flex;
position: relative;
padding: 0 30rpx;
align-items: center;
background-color: white;
white-space: nowrap;
.member-name {
flex: 1;
padding-left: 20rpx;
font-size: 30rpx;
font-weight: 600;
line-height: 60rpx;
white-space: nowrap;
overflow: hidden;
}
}
}
</style>
+202
View File
@@ -0,0 +1,202 @@
<template>
<view class="chat-item" :class="active ? 'active' : ''">
<!--rich-text中的表情包会屏蔽事件,所以这里用一个遮罩层捕获点击事件 -->
<view class="mask" @tap="showChatBox()"></view>
<view class="left">
<head-image :url="chat.headImage" :name="chat.showName" :online="online"></head-image>
</view>
<view class="chat-right">
<view class="chat-name">
<view class="chat-name-text">
<view>{{ chat.showName }}</view>
</view>
<view class="chat-time">{{ $date.toTimeText(chat.lastSendTime, true) }}</view>
</view>
<view class="chat-content">
<view class="chat-at-text">{{ atText }}</view>
<view class="chat-send-name" v-if="isShowSendName">{{
chat.sendNickName + ':&nbsp;'
}}</view>
<view v-if="!isTextMessage" class="chat-content-text">{{ chat.lastContent }}</view>
<rich-text v-else class="chat-content-text" :nodes="nodesText"></rich-text>
<view v-if="chat.isDnd" class="icon iconfont icon-dnd"></view>
<uni-badge v-else-if="chat.unreadCount > 0" :max-num="99" :text="chat.unreadCount" />
</view>
</view>
</view>
</template>
<script>
export default {
name: 'chatItem',
data() {
return {};
},
props: {
chat: {
type: Object,
},
index: {
type: Number,
},
active: {
type: Boolean,
default: false,
},
},
methods: {
showChatBox() {
// 初始化期间进入会话会导致消息不刷新
if (!this.configStore.appInit || this.chatStore.loading) {
uni.showToast({
title: '正在初始化页面,请稍后...',
icon: 'none',
});
return;
}
uni.navigateTo({
url: '/pages/message/chat-box?chatIdx=' + this.index,
});
},
},
computed: {
isShowSendName() {
if (!this.chat.sendNickName) {
return false;
}
let size = this.chat.messages.length;
if (size == 0) {
return false;
}
// 只有群聊的普通消息需要显示名称
let lastMsg = this.chat.messages[size - 1];
return this.$msgType.isNormal(lastMsg.type);
},
atText() {
if (this.chat.atMe) {
return '[有人@我]';
} else if (this.chat.atAll) {
return '[@全体成员]';
}
return '';
},
isTextMessage() {
if (this.chat.messages.length == 0) {
return false;
}
let idx = this.chat.messages.length - 1;
let messageType = this.chat.messages[idx].type;
return messageType == this.$enums.MESSAGE_TYPE.TEXT;
},
nodesText() {
let text = this.$str.html2Escape(this.chat.lastContent);
return this.$emo.transform(text, 'emoji-small');
},
online() {
if (this.chat.type == 'PRIVATE') {
let friend = this.friendStore.findFriend(this.chat.targetId);
return friend && friend.online;
}
return false;
},
},
};
</script>
<style scoped lang="scss">
.chat-item {
height: 96rpx;
display: flex;
margin-bottom: 2rpx;
position: relative;
padding: 18rpx 20rpx;
align-items: center;
background-color: white;
white-space: nowrap;
&:hover {
background-color: $im-bg-active;
}
&.active {
background-color: $im-bg-active;
}
.mask {
position: absolute;
width: 100%;
height: 100%;
left: 0;
right: 0;
z-index: 99;
}
.left {
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: 100rpx;
height: 100rpx;
}
.chat-right {
height: 100%;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding-left: 20rpx;
text-align: left;
overflow: hidden;
.chat-name {
display: flex;
.chat-name-text {
flex: 1;
font-size: $im-font-size-large;
white-space: nowrap;
overflow: hidden;
display: flex;
align-items: center;
}
.chat-time {
font-size: $im-font-size-smaller-extra;
color: $im-text-color-lighter;
text-align: right;
white-space: nowrap;
overflow: hidden;
}
}
.chat-content {
display: flex;
font-size: $im-font-size-smaller;
color: $im-text-color-lighter;
padding-top: 8rpx;
align-items: center;
.chat-at-text {
color: $im-color-danger;
}
.chat-send-name {
font-size: $im-font-size-smaller;
}
.chat-content-text {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.icon {
font-size: $im-font-size;
}
}
}
}
</style>
@@ -0,0 +1,546 @@
<template>
<view class="chat-message-item">
<view class="message-tip" v-if="msgInfo.type == $enums.MESSAGE_TYPE.TIP_TEXT">
{{ msgInfo.content }}
</view>
<view class="message-tip" v-else-if="msgInfo.type == $enums.MESSAGE_TYPE.TIP_TIME">
{{ $date.toTimeText(msgInfo.sendTime) }}
</view>
<view class="message-normal" v-else-if="isNormal" :class="{ 'message-mine': msgInfo.selfSend }">
<head-image class="avatar" @longpress.prevent="$emit('longPressHead')" :id="msgInfo.sendId" :url="headImage"
:name="showName" size="small"></head-image>
<view class="content">
<view v-if="msgInfo.groupId && !msgInfo.selfSend" class="top">
<text class="name">{{ showName }}</text>
</view>
<view class="bottom">
<view class="message-content-wrapper">
<view v-if="msgInfo.type == $enums.MESSAGE_TYPE.TEXT">
<long-press-menu :items="menuItems" @select="onSelectMenu">
<!-- up-parse支持点击a标签,但是不支持显示emo表情,也不支持换行 -->
<up-parse v-if="$url.containUrl(msgInfo.content)&&!$emo.containEmoji(msgInfo.content)"
class="message-text" :showImgMenu="false" :content="nodesText"></up-parse>
<!-- rich-text支持显示emo表情以及消息换行,但是不支持点击a标签 -->
<rich-text v-else class="message-text" :nodes="nodesText"></rich-text>
</long-press-menu>
</view>
<view class="message-image" v-else-if="msgInfo.type == $enums.MESSAGE_TYPE.IMAGE">
<long-press-menu :items="menuItems" @select="onSelectMenu">
<view class="image-box">
<image class="send-image" :style="imageStyle" mode="aspectFill"
:src="contentData.thumbUrl" lazy-load="true" @click.stop="onShowFullImage()">
</image>
<loading v-if="sending"></loading>
</view>
</long-press-menu>
</view>
<view class="message-file" v-else-if="msgInfo.type == $enums.MESSAGE_TYPE.FILE">
<long-press-menu :items="menuItems" @select="onSelectMenu">
<view class="file-box">
<view class="file-info">
<uni-link class="file-name" :text="contentData.name" showUnderLine="true"
color="#007BFF" :href="contentData.url"></uni-link>
<view class="file-size">{{ fileSize }}</view>
</view>
<view class="file-icon iconfont icon-file"></view>
<loading v-if="sending"></loading>
</view>
</long-press-menu>
</view>
<long-press-menu v-else-if="msgInfo.type == $enums.MESSAGE_TYPE.AUDIO" :items="menuItems"
@select="onSelectMenu">
<view class="message-audio message-text" @click="onPlayAudio()">
<text class="iconfont icon-voice-play"></text>
<text class="chat-audio-text">{{ contentData.duration + '"' }}</text>
<text v-if="audioPlayState == 'PAUSE'" class="iconfont icon-play"></text>
<text v-if="audioPlayState == 'PLAYING'" class="iconfont icon-pause"></text>
</view>
</long-press-menu>
<long-press-menu v-if="isAction" :items="menuItems" @select="onSelectMenu">
<view class="chat-realtime message-text" @click="$emit('call')">
<text v-if="msgInfo.type == $enums.MESSAGE_TYPE.ACT_RT_VOICE"
class="iconfont icon-chat-voice"></text>
<text v-if="msgInfo.type == $enums.MESSAGE_TYPE.ACT_RT_VIDEO"
class="iconfont icon-chat-video"></text>
<text>{{ msgInfo.content }}</text>
</view>
</long-press-menu>
<view v-if="sending&&(isTextMessage||isAudioMessage)" class="sending">
<loading :size="40" icon-color="#656adf" :mask="false"></loading>
</view>
<view v-else-if="sendFail" @click="onSendFail"
class="send-fail iconfont icon-warning-circle-fill"></view>
</view>
<view class="message-status" v-if="!isAction && msgInfo.selfSend && !msgInfo.groupId">
<text class="chat-readed" v-if="msgInfo.status == $enums.MESSAGE_STATUS.READED">已读</text>
<text class="chat-unread" v-else>未读</text>
</view>
<view class="chat-receipt" v-if="msgInfo.receipt" @click="onShowReadedBox">
<text v-if="msgInfo.receiptOk" class="tool-icon iconfont icon-ok"></text>
<text v-else>{{ msgInfo.readedCount }}人已读</text>
</view>
</view>
</view>
</view>
<chat-group-readed ref="chatGroupReaded" :groupMembers="groupMembers" :msgInfo="msgInfo"></chat-group-readed>
</view>
</template>
<script>
export default {
name: "chat-message-item",
props: {
headImage: {
type: String,
required: true
},
showName: {
type: String,
required: true
},
msgInfo: {
type: Object,
required: true
},
groupMembers: {
type: Array
}
},
data() {
return {
audioPlayState: 'STOP',
innerAudioContext: null,
menu: {
show: false,
style: ""
}
}
},
methods: {
onSendFail() {
this.$emit("resend", this.msgInfo);
},
onPlayAudio() {
// 初始化音频播放器
if (!this.innerAudioContext) {
this.innerAudioContext = uni.createInnerAudioContext();
let url = this.contentData.url;
this.innerAudioContext.src = url;
this.innerAudioContext.onEnded((e) => {
console.log('停止')
this.audioPlayState = "STOP"
this.emit();
})
this.innerAudioContext.onError((e) => {
this.audioPlayState = "STOP"
console.log("播放音频出错");
console.log(e)
this.emit();
});
}
if (this.audioPlayState == 'STOP') {
this.innerAudioContext.play();
this.audioPlayState = "PLAYING";
} else if (this.audioPlayState == 'PLAYING') {
this.innerAudioContext.pause();
this.audioPlayState = "PAUSE"
} else if (this.audioPlayState == 'PAUSE') {
this.innerAudioContext.play();
this.audioPlayState = "PLAYING"
}
this.emit();
},
onSelectMenu(item) {
this.$emit(item.key.toLowerCase(), this.msgInfo);
this.menu.show = false;
},
onShowFullImage() {
let imageUrl = this.contentData.originUrl;
uni.previewImage({
urls: [imageUrl]
})
},
onShowReadedBox() {
this.$refs.chatGroupReaded.open();
},
emit() {
this.$emit("audioStateChange", this.audioPlayState, this.msgInfo);
},
stopPlayAudio() {
if (this.innerAudioContext) {
this.innerAudioContext.stop();
this.innerAudioContext = null;
this.audioPlayState = "STOP"
}
}
},
computed: {
sending() {
return this.msgInfo.status == this.$enums.MESSAGE_STATUS.SENDING;
},
sendFail() {
return this.msgInfo.status == this.$enums.MESSAGE_STATUS.FAILED;
},
contentData() {
return JSON.parse(this.msgInfo.content)
},
fileSize() {
let size = this.contentData.size;
if (size > 1024 * 1024) {
return Math.round(size / 1024 / 1024) + "M";
}
if (size > 1024) {
return Math.round(size / 1024) + "KB";
}
return size + "B";
},
menuItems() {
let items = [];
if (this.msgInfo.type == this.$enums.MESSAGE_TYPE.TEXT) {
items.push({
key: 'COPY',
name: '复制',
icon: 'bars'
});
}
if (this.msgInfo.selfSend && this.msgInfo.id > 0) {
items.push({
key: 'RECALL',
name: '撤回',
icon: 'refreshempty'
});
}
items.push({
key: 'DELETE',
name: '删除',
icon: 'trash',
color: '#e64e4e'
});
if (this.msgInfo.type == this.$enums.MESSAGE_TYPE.FILE) {
items.push({
key: 'DOWNLOAD',
name: '下载并打开',
icon: 'download'
});
}
return items;
},
isTextMessage() {
return this.msgInfo.type == this.$enums.MESSAGE_TYPE.TEXT
},
isAudioMessage() {
return this.msgInfo.type == this.$enums.MESSAGE_TYPE.AUDIO
},
isAction() {
return this.$msgType.isAction(this.msgInfo.type);
},
isNormal() {
const type = this.msgInfo.type;
return this.$msgType.isNormal(type) || this.$msgType.isAction(type)
},
nodesText() {
let color = this.msgInfo.selfSend ? 'white' : '';
let text = this.$str.html2Escape(this.msgInfo.content)
text = this.$url.replaceURLWithHTMLLinks(text, color)
return this.$emo.transform(text, 'emoji-normal')
},
imageStyle() {
console.log(uni.getSystemInfo())
let maxSize = uni.getSystemInfoSync().windowWidth * 0.6;
let minSize = uni.getSystemInfoSync().windowWidth * 0.2;
let width = this.contentData.width;
let height = this.contentData.height;
if (width && height) {
let ratio = Math.min(width, height) / Math.max(width, height);
let w = Math.max(width > height ? maxSize : ratio * maxSize, minSize);
let h = Math.max(width > height ? ratio * maxSize : maxSize, minSize);
return `width: ${w}px;height:${h}px;`
} else {
// 兼容历史版本,历史数据没有记录宽高
return `max-width: ${maxSize}px;min-width:100px;max-height: ${maxSize}px;min-height:100px;`
}
}
}
}
</script>
<style scoped lang="scss">
.chat-message-item {
padding: 2rpx 20rpx;
.message-tip {
line-height: 60rpx;
text-align: center;
color: $im-text-color-lighter;
font-size: $im-font-size-smaller-extra;
padding: 10rpx;
}
.message-normal {
position: relative;
margin-bottom: 22rpx;
padding-left: 110rpx;
min-height: 80rpx;
.avatar {
position: absolute;
top: 0;
left: 0;
}
.content {
text-align: left;
.top {
display: flex;
flex-wrap: nowrap;
align-items: center;
.name {
color: $im-text-color-lighter;
font-size: $im-font-size-smaller;
line-height: $im-font-size-smaller;
height: $im-font-size-smaller;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.bottom {
display: inline-block;
padding-right: 80rpx;
margin-top: 5rpx;
.message-content-wrapper {
position: relative;
display: flex;
align-items: center;
.sending {
position: relative;
margin: 0 6rpx;
.icon-loading {
color: $im-color-primary;
}
}
.send-fail {
color: #e60c0c;
font-size: 50rpx;
margin: 0 5rpx;
}
.message-text {
position: relative;
line-height: 1.6;
margin-top: 10rpx;
padding: 16rpx 24rpx;
background-color: $im-bg;
border-radius: 20rpx;
color: $im-text-color;
font-size: $im-font-size;
text-align: left;
display: block;
word-break: break-word;
white-space: pre-line;
&:after {
content: "";
position: absolute;
left: -20rpx;
top: 26rpx;
width: 6rpx;
height: 6rpx;
border-style: solid dashed dashed;
border-color: $im-bg transparent transparent;
overflow: hidden;
border-width: 18rpx;
}
}
.message-image {
display: flex;
flex-wrap: nowrap;
flex-direction: row;
align-items: center;
.image-box {
position: relative;
.send-image {
cursor: pointer;
border-radius: 10rpx;
background: $im-bg;
border: 6rpx solid $im-color-primary-light-5;
}
}
.send-fail {
color: $im-color-danger;
font-size: $im-font-size;
cursor: pointer;
margin: 0 20px;
}
}
.message-file {
display: flex;
flex-wrap: nowrap;
flex-direction: row;
align-items: center;
cursor: pointer;
.file-box {
position: relative;
display: flex;
flex-wrap: nowrap;
align-items: center;
min-height: 60px;
border-radius: 4px;
padding: 10px 15px;
box-shadow: $im-box-shadow-dark;
.file-info {
flex: 1;
height: 100%;
text-align: left;
font-size: 14px;
width: 300rpx;
.file-name {
font-weight: 600;
margin-bottom: 15px;
word-break: break-all;
}
}
.file-icon {
font-size: 80rpx;
color: #d42e07;
}
}
.send-fail {
color: #e60c0c;
font-size: 50rpx;
cursor: pointer;
margin: 0 20rpx;
}
}
.message-audio {
display: flex;
align-items: center;
.chat-audio-text {
padding-right: 8px;
}
.icon-voice-play {
font-size: 18px;
padding-right: 8px;
}
}
}
.chat-realtime {
display: flex;
align-items: center;
.iconfont {
font-size: 20px;
padding-right: 8px;
}
}
.message-status {
line-height: $im-font-size-smaller-extra;
font-size: $im-font-size-smaller-extra;
padding-top: 2rpx;
.chat-readed {
display: block;
padding-top: 2rpx;
color: $im-text-color-lighter;
}
.chat-unread {
color: $im-color-danger;
}
}
.chat-receipt {
font-size: $im-font-size-smaller;
color: $im-text-color-lighter;
font-weight: 600;
.icon-ok {
font-size: 20px;
color: $im-color-success;
}
}
}
}
&.message-mine {
text-align: right;
padding-left: 0;
padding-right: 110rpx;
.avatar {
left: auto;
right: 0;
}
.content {
text-align: right;
.bottom {
padding-left: 80rpx;
padding-right: 0;
.message-content-wrapper {
flex-direction: row-reverse;
}
.message-text {
margin-left: 10px;
background-color: $im-color-primary-light-2;
color: #fff;
&:after {
left: auto;
right: -9px;
border-top-color: $im-color-primary-light-2;
}
}
.message-audio {
flex-direction: row-reverse;
.chat-audio-text {
padding-right: 0;
padding-left: 8px;
}
.icon-voice-play {
transform: rotateY(180deg);
}
}
.chat-realtime {
display: flex;
flex-direction: row-reverse;
.iconfont {
transform: rotateY(180deg);
}
}
}
}
}
}
}
</style>
+243
View File
@@ -0,0 +1,243 @@
<template>
<view class="chat-record">
<view class="chat-record-bar" :class="{ recording: recording }" id="chat-record-bar" @click.stop=""
@touchstart.prevent="onStartRecord" @touchmove.prevent="onTouchMove" @touchend.prevent="onEndRecord">
{{ recording ? '正在录音' : '长按 说话' }}</view>
<view v-if="recording" class="chat-record-window" :style="recordWindowStyle">
<view class="rc-wave">
<text class="note" style="--d: 0"></text>
<text class="note" style="--d: 1"></text>
<text class="note" style="--d: 2"></text>
<text class="note" style="--d: 3"></text>
<text class="note" style="--d: 4"></text>
<text class="note" style="--d: 5"></text>
<text class="note" style="--d: 6"></text>
</view>
<view class="rc-tip">{{ recordTip }}</view>
<view class="cancel-btn" @click="onCancel">
<uni-icons :class="moveToCancel ? 'red' : 'black'" type="clear" :size="moveToCancel ? 45 : 40"></uni-icons>
</view>
<view class="opt-tip" :class="moveToCancel ? 'red' : 'black'">{{ moveToCancel ? '松手取消' : '松手发送,上划取消' }}</view>
</view>
</view>
</template>
<script>
export default {
name: "chat-record",
data() {
return {
recording: false,
moveToCancel: false,
recordBarTop: 0,
druation: 0,
rcTimer: null
}
},
methods: {
onTouchMove(e) {
const moveY = e.touches[0].clientY;
this.moveToCancel = moveY < this.recordBarTop - 40;
},
onCancel() {
if (this.recording) {
this.moveToCancel = true;
this.onEndRecord();
}
},
onStartRecord() {
/* 用户第一次使用语音会唤醒录音权限请求,此时会导致@touchend失效,
一直处于录音状态,这里允许用户再次点击发送语音并结束录音 */
if (this.recording) {
return;
}
console.log("开始录音")
this.moveToCancel = false;
this.initRecordBar();
if(!this.$rc.checkIsEnable()){
return;
}
this.$rc.start().then(() => {
this.recording = true;
console.log("开始录音成功")
// 开始计时
this.startTimer();
}).catch((e) => {
console.log("录音失败" + JSON.stringify(e))
uni.showToast({
title: "录音失败",
icon: "none"
});
});
},
onEndRecord() {
if(!this.recording){
return;
}
this.recording = false;
// 停止计时
this.stopTimer();
// 停止录音
this.$rc.close();
// 触屏位置是否移动到了取消区域
if (this.moveToCancel) {
console.log("录音取消")
return;
}
// 大于1秒才发送
if (this.druation <= 1) {
uni.showToast({
title: "说话时间太短",
icon: 'none'
})
return;
}
this.$rc.upload().then((data) => {
this.$emit("send", data);
}).catch((e) => {
uni.showToast({
title: e,
icon: 'none'
})
})
},
startTimer() {
this.druation = 0;
this.stopTimer();
this.rcTimer = setInterval(() => {
this.druation++;
// 大于60s,直接结束
if (this.druation >= 60) {
this.onEndRecord();
}
}, 1000)
},
stopTimer() {
this.rcTimer && clearInterval(this.rcTimer);
this.rcTimer = null;
},
initRecordBar() {
const query = uni.createSelectorQuery().in(this);
query.select('#chat-record-bar').boundingClientRect((rect) => {
// 顶部高度位置
this.recordBarTop = rect.top;
}).exec()
}
},
computed: {
recordWindowStyle() {
const windowHeight = uni.getSystemInfoSync().windowHeight;
const bottom = windowHeight - this.recordBarTop + 12;
return `bottom:${bottom}px;`
},
recordTip() {
if (this.druation > 50) {
return `${60 - this.druation}s后将停止录音`;
}
return `录音时长:${this.druation}s`;
}
},
unmounted() {
this.stopTimer();
this.recording = false;
}
}
</script>
<style lang="scss" scoped>
.chat-record {
.rc-wave {
display: flex;
align-items: flex-end;
justify-content: center;
position: relative;
height: 80rpx;
.note {
background: linear-gradient(to top, $im-color-primary-light-1 0%, $im-color-primary-light-6 100%);
width: 4px;
height: 50%;
border-radius: 5rpx;
margin-right: 4px;
animation: loading 0.5s infinite linear;
animation-delay: calc(0.1s * var(--d));
@keyframes loading {
0% {
background-image: linear-gradient(to right, $im-color-primary-light-1 0%, $im-color-primary-light-6 100%);
height: 20%;
border-radius: 5rpx;
}
50% {
background-image: linear-gradient(to top, $im-color-primary-light-1 0%, $im-color-primary-light-6 100%);
height: 80%;
border-radius: 5rpx;
}
100% {
background-image: linear-gradient(to top, $im-color-primary-light-1 0%, $im-color-primary-light-6 100%);
height: 20%;
border-radius: 5rpx;
}
}
}
}
.chat-record-bar {
padding: 10rpx;
margin: 10rpx;
border-radius: 10rpx;
text-align: center;
box-shadow: $im-box-shadow;
&.recording {
background-color: $im-color-primary;
color: #fff;
}
}
.chat-record-window {
position: fixed;
left: 0;
right: 0;
height: 360rpx;
background-color: rgba(255, 255, 255, 0.95);
padding: 30rpx;
.icon-microphone {
text-align: center;
font-size: 80rpx;
padding: 10rpx;
}
.rc-tip {
text-align: center;
font-size: $im-font-size-small;
color: $im-text-color-light;
margin-top: 20rpx;
}
.cancel-btn {
text-align: center;
margin-top: 40rpx;
height: 80rpx;
}
.opt-tip {
text-align: center;
font-size: 30rpx;
padding: 20rpx;
}
.red {
color: $im-color-danger !important;
}
}
}
</style>
+122
View File
@@ -0,0 +1,122 @@
<template>
<view>
<lsj-upload ref="lsjUpload" :height="'100%'" :option="option" @uploadEnd="onUploadEnd" @change="onChange"
:size="maxSize" :instantly="true">
<slot></slot>
</lsj-upload>
</view>
</template>
<script>
import UNI_APP from '@/.env.js';
export default {
name: "file-upload",
data() {
return {
fileMap: new Map(),
option: {
url: UNI_APP.BASE_URL + '/file/upload',
name: 'file',
header: {
accessToken: uni.getStorageSync('loginInfo').accessToken
}
}
}
},
props: {
maxSize: {
type: Number,
default: 10
},
onBefore: {
type: Function,
default: null
},
onSuccess: {
type: Function,
default: null
},
onError: {
type: Function,
default: null
}
},
methods: {
show() {
this.$refs.lsjUpload.show();
},
hide() {
this.$refs.lsjUpload.hide();
},
onUploadEnd(item) {
let file = this.fileMap.get(item.path);
if (item.type == 'fail') {
this.onError(file)
return;
}
let res = JSON.parse(item.responseText);
if (res.code == 200) {
// 上传成功
this.onOk(file, res);
} else if (res.code == 401) {
// token已过期,重新获取token
this.refreshToken().then((res) => {
let newToken = res.data.accessToken;
this.option.header.accessToken = newToken;
this.$refs.lsjUpload.setData(this.option);
// 重新上传
this.$refs.lsjUpload.upload(file.name);
}).catch(() => {
this.onError(file, res);
})
} else {
// 上传失败
this.onError(file, res);
}
},
onChange(files) {
if (!files.size) {
return;
}
files.forEach((file, name) => {
if (!this.fileMap.has(file.path)) {
this.onBefore && this.onBefore(file)
this.fileMap.set(file.path, file);
}
})
},
onOk(file, res) {
this.fileMap.delete(file.path);
this.$refs.lsjUpload.clear(file.name);
this.onSuccess && this.onSuccess(file, res);
},
onFailed(file, res) {
this.fileMap.delete(file.path);
this.$refs.lsjUpload.clear(file.name);
this.onError && this.onError(file, res);
},
refreshToken() {
return new Promise((resolve, reject) => {
let loginInfo = uni.getStorageSync('loginInfo')
uni.request({
method: 'PUT',
url: UNI_APP.BASE_URL + '/refreshToken',
header: {
refreshToken: loginInfo.refreshToken
},
success: (res) => {
resolve(res.data);
},
fail: (res) => {
reject(res);
}
})
})
}
}
}
</script>
<style></style>
@@ -0,0 +1,82 @@
<template>
<view class="friend-item" @click="showFriendInfo()">
<head-image :name="friend.nickName" :online="friend.online" :url="friend.headImage" size="small"></head-image>
<view class="friend-info">
<view class="friend-name">{{ friend.nickName }}</view>
<view class="friend-online">
<image v-show="friend.onlineWeb" class="online" src="/static/image/online_web.png" title="电脑设备在线" />
<image v-show="friend.onlineApp" class="online" src="/static/image/online_app.png" title="移动设备在线" />
</view>
<slot></slot>
</view>
</view>
</template>
<script>
export default {
name: "frined-item",
data() {
return {}
},
methods: {
showFriendInfo() {
if (this.detail) {
uni.navigateTo({
url: "/pages/common/user-info?id=" + this.friend.id
})
}
}
},
props: {
friend: {
type: Object
},
detail: {
type: Boolean,
default: true
}
}
}
</script>
<style scope lang="scss">
.friend-item {
height: 90rpx;
display: flex;
margin-bottom: 1rpx;
position: relative;
padding: 10rpx;
padding-left: 20rpx;
align-items: center;
background-color: white;
white-space: nowrap;
&:hover {
background-color: $im-bg;
}
.friend-info {
flex: 1;
display: flex;
padding-left: 20rpx;
text-align: left;
.friend-name {
flex: 1;
font-size: $im-font-size;
white-space: nowrap;
overflow: hidden;
}
.friend-online {
margin-top: 4rpx;
.online {
padding-right: 4rpx;
width: 24rpx;
height: 24rpx;
}
}
}
}
</style>
+58
View File
@@ -0,0 +1,58 @@
<template>
<view class="group-item" @click="showGroupInfo()">
<head-image :name="group.showGroupName" :url="group.headImageThumb" size="small"></head-image>
<view class="group-name">
<view>{{ group.showGroupName }}</view>
</view>
</view>
</template>
<script>
export default {
name: "group-item",
data() {
return {}
},
methods: {
showGroupInfo() {
uni.navigateTo({
url: "/pages/group/group-info?id=" + this.group.id
})
},
},
props: {
group: {
type: Object
}
}
}
</script>
<style scope lang="scss">
.group-item {
height: 90rpx;
display: flex;
margin-bottom: 2rpx;
position: relative;
padding: 18rpx 20rpx;
align-items: center;
background-color: white;
white-space: nowrap;
&:hover {
background-color: $im-bg-active;
}
&.active {
background-color: $im-bg-active;
}
.group-name {
font-size: $im-font-size;
padding-left: 20rpx;
text-align: left;
white-space: nowrap;
overflow: hidden;
}
}
</style>
@@ -0,0 +1,180 @@
<template>
<uni-popup ref="popup" type="bottom">
<view class="group-member-selector">
<view class="top-bar">
<view class="top-tip">选择成员</view>
<button class="top-btn" type="warn" size="mini" @click="onClean()">清空 </button>
<button class="top-btn" type="primary" size="mini" @click="onOk()">确定({{ checkedIds.length }})
</button>
</view>
<scroll-view v-show="checkedIds.length > 0" scroll-x="true" scroll-left="120">
<view class="checked-users">
<view v-for="m in checkedMembers" class="user-item" :key="m.userId">
<head-image :name="m.showNickName" :url="m.headImage" :size="60"></head-image>
</view>
</view>
</scroll-view>
<view class="search-bar">
<uni-search-bar v-model="searchText" cancelButton="none" placeholder="搜索"></uni-search-bar>
</view>
<view class="member-items">
<virtual-scroller :items="showMembers">
<template v-slot="{ item }">
<view class="member-item" @click="onSwitchChecked(item)">
<head-image :name="item.showNickName" :online="item.online" :url="item.headImage"
:size="90"></head-image>
<view class="member-name">{{ item.showNickName }}
</view>
<view class="member-checked">
<radio :checked="item.checked" :disabled="item.locked"
@click.stop="onSwitchChecked(item)" />
</view>
</view>
</template>
</virtual-scroller>
</view>
</view>
</uni-popup>
</template>
<script>
export default {
name: "group-member-selector",
props: {
group: {
type: Object
},
members: {
type: Array
},
maxSize: {
type: Number,
default: 50
}
},
data() {
return {
searchText: "",
};
},
methods: {
init(checkedIds, lockedIds, hideIds) {
this.members.forEach((m) => {
m.checked = checkedIds.indexOf(m.userId) >= 0;
m.locked = lockedIds.indexOf(m.userId) >= 0;
m.hide = hideIds.indexOf(m.userId) >= 0;
});
},
open() {
this.$refs.popup.open();
},
onSwitchChecked(m) {
if (!m.locked) {
m.checked = !m.checked;
}
// 达到选择上限
if (this.maxSize > 0 && this.checkedIds.length > this.maxSize) {
m.checked = false;
uni.showToast({
title: `最多选择${this.maxSize}位用户`,
icon: "none"
})
}
},
onClean() {
this.members.forEach((m) => {
if (!m.locked && m.checked) {
m.checked = false;
}
})
},
onOk() {
this.$refs.popup.close();
this.$emit("complete", this.checkedIds)
}
},
computed: {
checkedIds() {
return this.checkedMembers.map(m => m.userId)
},
checkedMembers() {
return this.members.filter((m) => !m.quit && !m.hide && m.checked);
},
showMembers() {
return this.members.filter(m => !m.quit && !m.hide && m.showNickName.includes(this.searchText))
}
}
}
</script>
<style lang="scss" scoped>
.group-member-selector {
position: relative;
display: flex;
flex-direction: column;
background-color: white;
padding: 10rpx;
border-radius: 15rpx 15rpx 0 0;
overflow: hidden;
.top-bar {
display: flex;
align-items: center;
height: 70rpx;
padding: 10rpx 30rpx;
.top-tip {
flex: 1;
}
.top-btn {
margin-left: 10rpx;
}
}
.checked-users {
display: flex;
align-items: center;
height: 90rpx;
padding: 0 30rpx;
.user-item {
padding: 3rpx;
}
}
.member-items {
position: relative;
flex: 1;
overflow: hidden;
.member-item {
height: 120rpx;
display: flex;
position: relative;
padding: 0 30rpx;
align-items: center;
background-color: white;
white-space: nowrap;
.member-name {
display: flex;
align-items: center;
flex: 1;
padding-left: 20rpx;
font-size: 30rpx;
font-weight: 600;
line-height: 60rpx;
white-space: nowrap;
overflow: hidden;
.uni-tag {
margin-left: 5rpx;
}
}
}
}
}
</style>
@@ -0,0 +1,88 @@
<template>
<uni-popup ref="popup" type="center">
<uni-popup-dialog mode="base" :duration="2000" title="是否加入通话?" confirmText="加入" @confirm="onOk">
<div class="group-rtc-join">
<div class="host-info">
<div>发起人</div>
<head-image :name="rtcInfo.host.nickName" :url="rtcInfo.host.headImage" :size="80"></head-image>
</div>
<div class="user-info">
<div>{{ rtcInfo.userInfos.length + '人正在通话中' }}</div>
<scroll-view scroll-x="true" scroll-left="120">
<view class="user-list">
<view v-for="user in rtcInfo.userInfos" class="user-item" :key="user.id">
<head-image :name="user.nickName" :url="user.headImage" :size="80"></head-image>
</view>
</view>
</scroll-view>
</div>
</div>
</uni-popup-dialog>
</uni-popup>
</template>
<script>
export default {
data() {
return {
rtcInfo: {}
}
},
props: {
groupId: {
type: Number
}
},
methods: {
open(rtcInfo) {
this.rtcInfo = rtcInfo;
this.$refs.popup.open();
},
onOk() {
let users = this.rtcInfo.userInfos;
let mine = this.userStore.userInfo;
// 加入自己的信息
if (!users.find((user) => user.id == mine.id)) {
users.push({
id: mine.id,
nickName: mine.nickName,
headImage: mine.headImageThumb,
isCamera: false,
isMicroPhone: true
})
}
const userInfos = encodeURIComponent(JSON.stringify(users));
uni.navigateTo({
url: `/pages/chat/chat-group-video?groupId=${this.groupId}&isHost=false
&inviterId=${mine.id}&userInfos=${userInfos}`
})
}
}
}
</script>
<style lang="scss" scoped>
.group-rtc-join {
width: 100%;
.host-info {
font-size: 16px;
padding: 10px;
}
.user-info {
font-size: 16px;
padding: 10px;
}
.user-list {
display: flex;
align-items: center;
height: 90rpx;
.user-item {
padding: 3rpx;
}
}
}
</style>
+129
View File
@@ -0,0 +1,129 @@
<template>
<view class="head-image none-pointer-events" @click="showUserInfo($event)" :title="name">
<image class="avatar-image" v-if="url" :src="url" :style="avatarImageStyle" lazy-load="true"
mode="aspectFill" />
<view class="avatar-text" v-if="!url" :style="avatarTextStyle">
{{ name?.substring(0, 1).toUpperCase() }}
</view>
<view v-if="online" class="online" title="用户当前在线">
</view>
</view>
</template>
<script>
export default {
name: "head-image",
data() {
return {
colors: ["#5daa31", "#c7515a", "#e03697", "#85029b",
"#c9b455", "#326eb6"
]
}
},
props: {
id: {
type: Number
},
size: {
type: [Number, String],
default: 'default'
},
url: {
type: String
},
name: {
type: String,
default: null
},
radius: {
type: String,
default: "50%"
},
online: {
type: Boolean,
default: false
},
},
methods: {
showUserInfo(e) {
if (this.id && this.id > 0) {
uni.navigateTo({
url: "/pages/common/user-info?id=" + this.id
})
}
}
},
computed: {
_size() {
if (typeof this.size === 'number') {
return this.size;
} else if (typeof this.size === 'string') {
return {
'default': 96,
'small': 84,
'smaller': 72,
'mini': 60,
'minier': 48,
'lage': 108,
'lager': 120,
} [this.size]
}
},
avatarImageStyle() {
return `width:${this._size}rpx;
height:${this._size}rpx;`
},
avatarTextStyle() {
return `width: ${this._size}rpx;
height:${this._size}rpx;
background: linear-gradient(145deg,#ffffff20 25%,#00000060),${this.textColor};
font-size:${this._size * 0.45}rpx;
border-radius: ${this.radius};
`
},
textColor() {
if (!this.name) {
return '#fff';
}
let hash = 0;
for (var i = 0; i < this.name.length; i++) {
hash += this.name.charCodeAt(i);
}
return this.colors[hash % this.colors.length];
}
}
}
</script>
<style scoped lang="scss">
.head-image {
position: relative;
cursor: pointer;
.avatar-image {
position: relative;
overflow: hidden;
border-radius: 50%;
vertical-align: bottom;
}
.avatar-text {
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.online {
position: absolute;
right: -10%;
bottom: 0;
width: 24rpx;
height: 24rpx;
background: limegreen;
border-radius: 50%;
border: 6rpx solid white;
}
}
</style>
@@ -0,0 +1,100 @@
<template>
<view @click="selectAndUpload()">
<slot></slot>
</view>
</template>
<script>
import UNI_APP from '@/.env.js'
export default {
name: "image-upload",
data() {
return {
uploadHeaders: {
"accessToken": uni.getStorageSync('loginInfo').accessToken
}
}
},
props: {
maxCount: {
type: Number,
default: 1
},
maxSize: {
type: Number,
default: 5 * 1024 * 1024
},
sourceType: {
type: String,
default: 'album'
},
isPermanent: {
type: Boolean,
default: false
},
thumbSize: {
type: Number,
default: 50
},
onBefore: {
type: Function,
default: null
},
onSuccess: {
type: Function,
default: null
},
onError: {
type: Function,
default: null
}
},
methods: {
selectAndUpload() {
uni.chooseImage({
count: this.maxCount, //最多可以选择的图片张数,默认9
sourceType: [this.sourceType], //album 从相册选图,camera 使用相机,默认二者都有。如需直接开相机或直接选相册,请只使用一个选项
sizeType: ['original'], //original 原图,compressed 压缩图,默认二者都有
success: (res) => {
res.tempFiles.forEach((file) => {
if (!this.onBefore || this.onBefore(file)) {
// 调用上传图片的接口
this.uploadImage(file);
}
})
}
})
},
uploadImage(file) {
let action = `/image/upload?isPermanent=${this.isPermanent}&thumbSize=${this.thumbSize}`
uni.uploadFile({
url: UNI_APP.BASE_URL + action,
header: {
accessToken: uni.getStorageSync("loginInfo").accessToken
},
filePath: file.path, // 要上传文件资源的路径
name: 'file',
success: (res) => {
let data = JSON.parse(res.data);
if (data.code != 200) {
uni.showToast({
icon: "none",
title: data.message,
})
this.onError && this.onError(file, data);
} else {
this.onSuccess && this.onSuccess(file, data);
}
},
fail: (err) => {
console.log(err);
this.onError && this.onError(file, err);
}
})
}
}
}
</script>
<style></style>
+71
View File
@@ -0,0 +1,71 @@
<template>
<view class="loading-box" :style="loadingStyle">
<view class="rotate iconfont icon-loading" :style="icontStyle"></view>
<slot></slot>
</view>
</template>
<script>
export default {
data() {
return {}
},
props: {
size: {
type: Number,
default: 100
},
iconColor: {
type: String,
default: ''
},
mask: {
type: Boolean,
default: true
}
},
computed: {
icontStyle() {
let style = `font-size:${this.size}rpx;`;
if(this.iconColor){
style += `color: ${this.iconColor};`
}else if(this.mask){
style += 'color: #eee;'
}
return style;
},
loadingStyle() {
return this.mask ? "background: rgba(0, 0, 0, 0.3);" : "";
}
}
}
</script>
<style lang="scss" scoped>
.loading-box {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
z-index: 10000;
display: flex;
justify-content: center;
align-items: center;
}
.rotate {
animation: rotate 2s ease-in-out infinite;
}
@keyframes rotate {
from {
transform: rotate(0deg)
}
to {
transform: rotate(360deg)
}
}
</style>
@@ -0,0 +1,129 @@
<template>
<view class="long-press-menu none-pointer-events">
<view @longpress.prevent.stop="onLongPress($event)" @touchmove="onTouchMove" @touchend="onTouchEnd">
<slot></slot>
</view>
<view v-if="isShowMenu" class="menu-mask" @touchstart="onClose()" @contextmenu.prevent=""></view>
<view v-if="isShowMenu" class="menu" :style="menuStyle">
<view class="menu-item" v-for="(item) in items" :key="item.key" @click.prevent="onSelectMenu(item)">
<text :style="itemStyle(item)"> {{ item.name }}</text>
</view>
</view>
</view>
</template>
<script>
export default {
name: "long-press-menu",
data() {
return {
isShowMenu: false,
isTouchMove: false,
style: ""
}
},
props: {
items: {
type: Array
}
},
methods: {
onLongPress(e) {
if (this.isTouchMove) {
// 屏幕移动时不弹出
return;
}
uni.getSystemInfo({
success: (res) => {
let touches = e.touches[0];
let style = "";
/* 因 非H5端不兼容 style 属性绑定 Object ,所以拼接字符 */
if (touches.clientY > (res.windowHeight / 2)) {
style = `bottom:${res.windowHeight - touches.clientY + 20}px;`;
} else {
style = `top:${touches.clientY + 20}px;`;
}
if (touches.clientX > (res.windowWidth / 2)) {
style += `right:${res.windowWidth - touches.clientX}px;`;
} else {
style += `left:${touches.clientX}px;`;
}
this.menuStyle = style;
//
this.$nextTick(() => {
this.isShowMenu = true;
});
this.menuTouch = touches
}
})
},
onTouchMove(e) {
this.isTouchMove = true;
let touches = e.touches[0];
// 屏幕拖动大于50px时,取消菜单
if (Math.abs(touches.clientX - this.menuTouch.clientX) > 50 ||
Math.abs(touches.clientY - this.menuTouch.clientY) > 50) {
this.onClose();
}
},
onTouchEnd() {
this.isTouchMove = false;
},
onSelectMenu(item) {
this.$emit("select", item);
this.isShowMenu = false;
},
onClose() {
this.isShowMenu = false;
},
itemStyle(item) {
if (item.color) {
return `color:${item.color};`
}
return `color:#000;`;
}
}
}
</script>
<style lang="scss" scoped>
.long-press-menu {
.menu-mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
z-index: 999;
}
.menu {
position: fixed;
border-radius: 4px;
overflow: hidden;
background-color: #fff;
z-index: 1000;
box-shadow: $im-box-shadow-dark;
.menu-item {
height: 28px;
min-width: 120rpx;
line-height: 28px;
font-size: $im-font-size-small;
display: flex;
padding: 6px 20px;
justify-content: flex-start;
&:hover {
background: $im-bg-active;
}
.menu-icon {
margin-right: 10rpx;
}
}
}
}
</style>
+121
View File
@@ -0,0 +1,121 @@
<template>
<view class="im-nav-bar">
<!-- #ifdef APP-PLUS -->
<view style="height: var(--status-bar-height)"></view>
<!-- #endif -->
<view class="im-nav-bar-content">
<!-- #ifndef MP-WEIXIN -->
<view class="back" @click="handleBackClick" v-if="back">
<uni-icons type="back" :size="iconFontSize"></uni-icons>
</view>
<!-- #endif -->
<view class="title" v-if="title">
<slot></slot>
</view>
<view class="btn">
<uni-icons class="btn-item" v-if="search" type="search" :size="iconFontSize"
@click="$emit('search')"></uni-icons>
<uni-icons class="btn-item" v-if="add" type="plusempty" :size="iconFontSize"
@click="$emit('add')"></uni-icons>
<uni-icons class="btn-item" v-if="more" type="more-filled" :size="iconFontSize"
@click="$emit('more')"></uni-icons>
</view>
</view>
</view>
</template>
<script>
export default {
name: "nav-bar",
props: {
back: {
type: Boolean,
default: false
},
title: {
type: Boolean,
default: true
},
search: {
type: Boolean,
default: false
},
add: {
type: Boolean,
default: false
},
more: {
type: Boolean,
default: false
},
iconFontSize: {
type: Number,
default: 24
}
},
data() {
return {}
},
computed: {
height() {
}
},
methods: {
handleBackClick() {
uni.navigateBack({
delta: 1
})
}
}
}
</script>
<style scoped lang="scss">
.im-nav-bar {
background: $im-bg-linear;
position: fixed;
top: 0;
width: 100%;
color: $im-text-color;
border-bottom: 1px solid $im-border-light;
font-size: $im-font-size-large;
z-index: 99;
.im-nav-bar-content {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
height: $im-nav-bar-height;
.title {}
.back {
position: absolute;
left: 0;
height: 100%;
display: flex;
align-items: center;
padding: 12px;
font-size: 22px;
box-sizing: border-box;
}
.btn {
position: absolute;
right: 0;
height: 100%;
display: flex;
padding: 12px;
align-items: center;
box-sizing: border-box;
.btn-item {
margin-left: 8px;
}
}
}
}
</style>
@@ -0,0 +1,60 @@
<template>
<scroll-view scroll-y="true" :style="{height: height}" upper-threshold="200" @scrolltolower="onScrollToBottom"
scroll-with-animation="true">
<view v-for="(item, idx) in showItems" :key="idx">
<slot :item="item">
</slot>
</view>
</scroll-view>
</template>
<script>
export default {
name: "virtual-scroller",
data() {
return {
page: 1,
isInitEvent: false,
lockTip: false
}
},
props: {
height: {
type: String,
default: '60vh'
},
items: {
type: Array
},
size: {
type: Number,
default: 30
}
},
methods: {
onScrollToBottom(e) {
if (this.showMaxIdx >= this.items.length) {
this.showTip();
} else {
this.page++;
}
},
showTip() {
uni.showToast({
title: "已滚动至底部",
icon: 'none'
});
}
},
computed: {
showMaxIdx() {
return Math.min(this.page * this.size, this.items.length);
},
showItems() {
return this.items.slice(0, this.showMaxIdx);
}
}
}
</script>
<style scoped></style>
+64
View File
@@ -0,0 +1,64 @@
// 颜色
$im-color-primary: #3e45d7;
$im-color-primary-light-1: mix(#fff, $im-color-primary, 10%);
$im-color-primary-light-2: mix(#fff, $im-color-primary, 20%);
$im-color-primary-light-3: mix(#fff, $im-color-primary, 30%);
$im-color-primary-light-4: mix(#fff, $im-color-primary, 40%);
$im-color-primary-light-5: mix(#fff, $im-color-primary, 50%);
$im-color-primary-light-6: mix(#fff, $im-color-primary, 60%);
$im-color-primary-light-7: mix(#fff, $im-color-primary, 70%);
$im-color-primary-light-8: mix(#fff, $im-color-primary, 80%);
$im-color-primary-light-9: mix(#fff, $im-color-primary, 90%);
$im-color-primary-dark-1: mix(#000, $im-color-primary, 10%);
$im-color-primary-dark-2: mix(#000, $im-color-primary, 20%);
$im-color-primary-dark-3: mix(#000, $im-color-primary, 30%);
$im-color-primary-dark-4: mix(#000, $im-color-primary, 40%);
$im-color-success: #18bc37;
$im-color-warning: #f3a73f;
$im-color-danger: #e43d33;
$im-color-info: #8f939c;
// 文字颜色
$im-text-color: #000000;
$im-text-color-light: #6a6a6a;
$im-text-color-lighter: #909399;
$im-text-color-lighter-extra: #c7c7c7;
// 边框颜色
$im-border: #F0F0F0;
$im-border-light: #EDEDED;
$im-border-lighter: #DCDCDC;
$im-border-lighter-extra: #B9B9B9;
// 文字大小
$im-font-size: 30rpx;
$im-font-size-small: 28rpx;
$im-font-size-smaller: 26rpx;
$im-font-size-smaller-extra: 24rpx;
$im-font-size-large: 32rpx;
$im-font-size-larger: 34rpx;
$im-font-size-larger-extra: 36rpx;
// 阴影
$im-box-shadow: 0 2px 4px rgba(0, 0, 0, .12), 0 0 6px rgba(0, 0, 0, .04);
$im-box-shadow-light: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
$im-box-shadow-lighter: 0px 0px 6px rgba(0, 0, 0, .12);
$im-box-shadow-dark: 0px 1px 10px 2px rgba($color: #a5a4a4, $alpha: 0.5);
// 背景
$im-bg: #f8f9ff;
$im-bg-linear: linear-gradient(135deg, #f8f9ff 0%, #ffffff 100%);
$im-bg-active: #f7f8fc;
$im-bg-active-dark: $im-color-primary-light-9;
$im-bg-input: #fafafa;
// 标题
$im-title-size: 26px;
$im-title-size-1: 22px;
$im-title-size-2: 18px;
$font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, SimSun, sans-serif;
$im-nav-bar-height: 50px;
+258
View File
@@ -0,0 +1,258 @@
/** 原生button样式 **/
uni-button {
font-size: $im-font-size !important;
border-radius: 40rpx !important;
}
uni-button[type='primary'] {
color: #fff !important;
background: linear-gradient(135deg, $im-color-primary, lighten($im-color-primary, 15%)) !important;
}
uni-button[type='primary'][plain] {
color: $im-color-primary !important;
border: 1px solid $im-color-primary;
background-color: transparent;
}
uni-button[type='warn'] {
color: #fff !important;
background: linear-gradient(135deg, $im-color-danger, lighten($im-color-danger, 15%)) !important;
}
uni-button[type='warn'][plain] {
color: $im-color-danger !important;
border: 1px solid $im-color-danger !important;
background: transparent !important;
}
uni-button[size='mini'] {
font-size: $im-font-size-smaller !important;
}
// #ifdef MP-WEIXIN
// wx小程序只有button,没有uni-botton
button {
font-size: $im-font-size !important;
}
button[type='primary'] {
color: #fff !important;
background: linear-gradient(135deg, $im-color-primary, lighten($im-color-primary, 15%)) !important;
}
button[type='primary'][plain] {
color: $im-color-primary !important;
border: 2px solid $im-color-primary;
background: transparent;
}
button[type='warn'] {
color: #fff !important;
background: linear-gradient(135deg, $im-color-danger, lighten($im-color-danger, 15%)) !important;
}
button[type='warn'][plain] {
color: $im-color-danger !important;
border: 1px solid $im-color-danger !important;
background: transparent !important;
}
button[size='mini'] {
font-size: $im-font-size-smaller !important;
}
// #endif
.button-hover[type='primary'] {
color: #fff !important;
background-color: $im-color-primary-dark-1 !important;
}
/** uni-ui input激活后边框、图标颜色 **/
.uni-easyinput__content.is-focused:not(.is-input-error-border) {
border-color: $im-color-primary-light-2 !important;
.content-clear-icon {
color: $im-color-primary-light-2 !important;
}
}
/** 底部导航 **/
.uni-tabbar-bottom .uni-tabbar {
box-shadow: $im-box-shadow;
}
.uni-tabbar-border {
display: none;
}
.segmented-control {
border-color: $im-color-primary !important;
.segmented-control__item--button {
border-color: $im-color-primary !important;
}
.segmented-control__item--button--active {
background-color: $im-color-primary !important;
.segmented-control__text{
color: #fff !important;
}
}
.segmented-control__text{
color: $im-color-primary !important;
}
}
.uni-radio-input svg{
border-color: white !important;
background-color: $im-color-primary !important;
}
.uni-radio-input svg {
background-color: $im-color-primary !important;
border-color: $im-color-primary !important;
background-clip: content-box !important;
box-sizing: border-box;
border-radius: 50%;
transform: translate(-50%, -50%) scale(0.7)!important;
}
.uni-radio-input svg path{
fill: $im-color-primary !important;
}
.uni-radio-input {
background-color: white !important;
border-color: $im-color-primary !important;
}
.uni-radio-input-disabled {
background-color: rgb(225, 225, 225) !important;
border-color: rgb(209, 209, 209) !important;
opacity: 0.5;
}
.uni-section__content-title {
font-size: $im-font-size !important;
color: $im-text-color-light;
}
.uni-forms-item__label {
color: $im-text-color;
font-size: $im-font-size !important;
}
.uni-forms-item {
//margin-bottom: 8px !important;
}
.uni-easyinput__content-input {
font-size: $im-font-size !important;
}
.uni-easyinput__placeholder-class {
color: $im-text-color-lighter;
font-size: $im-font-size !important;;
}
.uni-easyinput__content-textarea {
font-size: $im-font-size !important;;
}
.uni-input-input:disabled {
color: $im-text-color-light;
}
.uni-forms-item.is-direction-top .uni-forms-item__label {
padding: 0 !important;
}
.uni-data-checklist .checklist-group .checklist-box .checklist-content .checklist-text {
font-size: $im-font-size !important;
}
.uni-card .uni-card__content {
color: unset !important;
padding: 10px 0 !important;
}
.uni-tag{
transform: scale(0.9);
margin-left: 5rpx;
border-radius: 12rpx !important;
font-weight: 600 !important;
white-space: nowrap;
}
.uni-switch-input-checked {
background-color: $im-color-primary-light-1 !important;
border-color: $im-color-primary-light-1 !important;
}
.uni-modal__title {
font-size: $im-font-size-larger !important;
}
.uni-modal__bd {
font-size: $im-font-size;
}
.uni-modal__ft {
font-size: $im-font-size;
line-height: 90rpx !important;
.uni-modal__btn_primary {
color: $im-color-primary !important;
}
}
.nav-bar {
height: 100rpx;
padding: 0 20rpx;
display: flex;
align-items: center;
border-bottom: 1rpx solid $im-border;
.nav-search {
flex: 1;
}
.nav-add {
cursor: pointer;
}
}
.bottom-btn {
margin: 40rpx 40rpx;
uni-button + uni-button {
margin-top: 20rpx;
}
}
.emoji-large {
width: 64rpx !important;
height: 64rpx !important;
vertical-align: bottom !important;
}
.emoji-normal {
width: 54rpx !important;
height: 54rpx !important;
vertical-align: bottom !important;
}
.emoji-small {
width: 36rpx !important;
height: 36rpx !important;
vertical-align: bottom !important;
}
.none-pointer-events {
uni-image img {
// 阻止微信默认长按菜单
pointer-events: none;
-webkit-pointer-events: none;
-ms-pointer-events: none;
-moz-pointer-events: none;
}
}
p {
margin-block-start: 1em;
margin-block-end: 1em;
}
+49
View File
@@ -7,6 +7,20 @@ import * as enums from './common/enums.js';
import * as date from './common/date';
import * as socketApi from './common/wssocket';
import * as messageType from './common/messageType';
// #ifdef H5
import * as recorder from './common/recorder-h5';
import ImageResize from 'quill-image-resize-mp';
import Quill from 'quill';
// 以下组件用于兼容部分手机聊天边框无法输入的问题
window.Quill = Quill;
window.ImageResize = { default: ImageResize };
// 调试器
// import VConsole from 'vconsole'
// new VConsole();
// #endif
// #ifndef H5
import * as recorder from './common/recorder-app';
// #endif
import App from './App.vue';
import { createSSRApp } from 'vue';
@@ -15,11 +29,46 @@ import { setupPinia } from './sheep/store';
import './main.scss';
import 'virtual:uno.css';
import useChatStore from '@/store/chatStore.js';
import useFriendStore from '@/store/friendStore.js';
import useGroupStore from '@/store/groupStore.js';
import useConfigStore from '@/store/configStore.js';
import useUserStore from '@/store/userStore.js';
import barGroup from '@/components/bar/bar-group';
import arrowBar from '@/components/bar/arrow-bar';
import btnBar from '@/components/bar/btn-bar';
import switchBar from '@/components/bar/switch-bar';
export function createApp() {
const app = createSSRApp(App);
setupPinia(app);
app.config.globalProperties.$http = request;
app.config.globalProperties.$wsApi = socketApi;
app.config.globalProperties.$msgType = messageType;
app.config.globalProperties.$emo = emotion;
app.config.globalProperties.$url = url;
app.config.globalProperties.$str = str;
app.config.globalProperties.$enums = enums;
app.config.globalProperties.$date = date;
app.config.globalProperties.$rc = recorder;
app.component('bar-group', barGroup);
app.component('arrow-bar', arrowBar);
app.component('btn-bar', btnBar);
app.component('switch-bar', switchBar);
// 初始化时再挂载store对象
app.config.globalProperties.$mountStore = () => {
app.config.globalProperties.chatStore = useChatStore();
app.config.globalProperties.friendStore = useFriendStore();
app.config.globalProperties.groupStore = useGroupStore();
app.config.globalProperties.configStore = useConfigStore();
app.config.globalProperties.userStore = useUserStore();
};
return {
app,
};
+28 -4
View File
@@ -101,7 +101,7 @@
}
},
{
"path": "pages/messages/messages",
"path": "pages/message/message",
"style": {
"navigationBarTitleText": "消息",
"navigationStyle": "custom"
@@ -147,6 +147,30 @@
}
]
},
{
"root": "pages/message",
"pages": [
{
"path": "chat-box",
"style": {
"mp-weixin": {
"disableScroll": true
},
"app-plus": {
// adjustPan窗体高度不变,但窗体上推、adjustResize屏幕高度=webview窗体高度+软键盘高度
"softinputMode": "adjustResize"
}
}
},
{
"path": "user-info",
"style": {
"navigationBarTitleText": "用户信息",
"navigationStyle": "custom"
}
}
]
},
{
"root": "pages/profile",
"pages": [
@@ -840,10 +864,10 @@
"selectedIconPath": "static/tabbar/moments-active.png"
},
{
"pagePath": "pages/messages/messages",
"pagePath": "pages/message/message",
"text": "消息",
"iconPath": "static/tabbar/messages.png",
"selectedIconPath": "static/tabbar/messages-active.png"
"iconPath": "static/tabbar/message.png",
"selectedIconPath": "static/tabbar/message-active.png"
},
{
"pagePath": "pages/profile/profile",
File diff suppressed because it is too large. Load diff
+138
View File
@@ -0,0 +1,138 @@
<template>
<web-view class="page chat-group-video" id="chat-video-wv" @message="onMessage" :src="url"></web-view>
</template>
<script>
import UNI_APP from '@/.env.js'
export default {
data() {
return {
url: "",
wv: '',
isHost: false,
groupId: null,
inviterId: null,
userInfos: []
}
},
methods: {
onMessage(e) {
this.onWebviewMessage(e.detail.data[0]);
},
onInsertMessage(msgInfo) {
},
onWebviewMessage(event) {
console.log("来自webview的消息:" + JSON.stringify(event))
switch (event.key) {
case "WV_READY":
this.initWebView();
break;
case "WV_CLOSE":
uni.navigateBack();
break;
case "INSERT_MESSAGE":
this.onInsertMessage(event.data);
break;
}
},
sendMessageToWebView(key, message) {
// 如果webview还没初始化好,则延迟100ms再推送
if (!this.wv) {
setTimeout(() => this.sendMessageToWebView(key, message), 100)
return;
}
let event = {
key: key,
data: message
}
// #ifdef APP-PLUS
this.wv.evalJS(`onEvent('${encodeURIComponent(JSON.stringify(event))}')`)
// #endif
// #ifdef H5
this.wv.postMessage(event, '*');
// #endif
},
initWebView() {
// #ifdef APP-PLUS
// APP的webview
this.wv = this.$scope.$getAppWebview().children()[0]
// #endif
// #ifdef H5
// H5的webview就是iframe
this.wv = document.getElementById('chat-video-wv').contentWindow
// #endif
},
initUrl() {
this.url = "/hybrid/html/rtc-group/index.html?";
this.url += "baseUrl=" + UNI_APP.BASE_URL;
this.url += "&groupId=" + this.groupId;
this.url += "&userId=" + this.userStore.userInfo.id;
this.url += "&inviterId=" + this.inviterId;
this.url += "&isHost=" + this.isHost;
this.url += "&loginInfo=" + JSON.stringify(uni.getStorageSync("loginInfo"));
this.url += "&userInfos=" + JSON.stringify(this.userInfos);
this.url += "&config=" + JSON.stringify(this.configStore.webrtc);
},
},
onBackPress() {
this.sendMessageToWebView("NAV_BACK", {})
},
onLoad(options) {
uni.$on('WS_RTC_GROUP', msg => {
// 推送给web-view处理
this.sendMessageToWebView("RTC_MESSAGE", msg);
})
// #ifdef H5
window.onmessage = (e) => {
this.onWebviewMessage(e.data.data.arg);
}
// #endif
// 是否发起人
this.isHost = JSON.parse(options.isHost);
// 发起者的用户
this.inviterId = options.inviterId;
// 解析页面跳转时带过来的好友信息
this.groupId = options.groupId;
// 邀请的用户信息
this.userInfos = JSON.parse(decodeURIComponent(options.userInfos));
// 构建url
this.initUrl();
},
onUnload() {
uni.$off('WS_RTC_GROUP')
}
}
</script>
<style lang="scss" scoped>
.chat-group-video {
.header {
display: flex;
justify-content: center;
align-items: center;
height: 60rpx;
padding: 5px;
background-color: white;
line-height: 50px;
font-size: 40rpx;
font-weight: 600;
.btn-side {
position: absolute;
line-height: 60rpx;
font-size: 28rpx;
cursor: pointer;
&.left {
left: 30rpx;
}
&.right {
right: 30rpx;
}
}
}
}
</style>
+100
View File
@@ -0,0 +1,100 @@
<template>
<web-view class="page chat-private-video" id="chat-video-wv" @message="onMessage" :src="url"></web-view>
</template>
<script>
import UNI_APP from '@/.env.js'
export default {
data() {
return {
url: "",
wv: '',
mode: "video",
isHost: true,
friend: {}
}
},
methods: {
onMessage(e) {
this.onWebviewMessage(e.detail.data[0]);
},
onWebviewMessage(event) {
console.log("来自webview的消息:" + JSON.stringify(event))
switch (event.key) {
case "WV_READY":
this.initWebView();
break;
case "WV_CLOSE":
uni.navigateBack();
break;
}
},
sendMessageToWebView(key, message) {
// 如果webview还没初始化好,则延迟100ms再推送
if (!this.wv) {
setTimeout(() => this.sendMessageToWebView(key, message), 100)
return;
}
let event = {
key: key,
data: message
}
// #ifdef APP-PLUS
this.wv.evalJS(`onEvent('${encodeURIComponent(JSON.stringify(event))}')`)
// #endif
// #ifdef H5
this.wv.postMessage(event, '*');
// #endif
},
initWebView() {
// #ifdef APP-PLUS
// APP的webview
this.wv = this.$scope.$getAppWebview().children()[0]
// #endif
// #ifdef H5
// H5的webview就是iframe
this.wv = document.getElementById('chat-video-wv').contentWindow
// #endif
},
initUrl() {
this.url = "/hybrid/html/rtc-private/index.html";
this.url += "?mode=" + this.mode;
this.url += "&isHost=" + this.isHost;
this.url += "&baseUrl=" + UNI_APP.BASE_URL;
this.url += "&loginInfo=" + JSON.stringify(uni.getStorageSync("loginInfo"));
this.url += "&userInfo=" + JSON.stringify(this.userStore.userInfo);
this.url += "&friend=" + JSON.stringify(this.friend);
this.url += "&config=" + JSON.stringify(this.configStore.webrtc);
},
},
onBackPress() {
this.sendMessageToWebView("NAV_BACK", {})
},
onLoad(options) {
uni.$on('WS_RTC_PRIVATE', msg => {
// 推送给web-view处理
this.sendMessageToWebView("RTC_MESSAGE", msg);
})
// #ifdef H5
window.onmessage = (e) => {
this.onWebviewMessage(e.data.data.arg);
}
// #endif
// 模式:视频呼叫/语音呼叫
this.mode = options.mode;
// 是否呼叫方
this.isHost = JSON.parse(options.isHost);
// 解析页面跳转时带过来的好友信息
this.friend = JSON.parse(decodeURIComponent(options.friend));
// 构建url
this.initUrl();
},
onUnload() {
uni.$off('WS_RTC_PRIVATE')
}
}
</script>
<style lang="scss" scoped>
.chat-web-view {}
</style>
+207
View File
@@ -0,0 +1,207 @@
<template>
<view class="tab-page">
<nav-bar search @search="onSearch()">消息</nav-bar>
<view v-if="loading" class="chat-loading">
<loading :size="50" :mask="false">
<view>消息接收中...</view>
</loading>
</view>
<view v-else-if="initializing" class="chat-loading">
<loading :size="50" :mask="false">
<view>正在初始化...</view>
</loading>
</view>
<view class="nav-bar" v-if="showSearch">
<view class="nav-search">
<uni-search-bar focus="true" radius="100" v-model="searchText" cancelButton="none"
placeholder="搜索"></uni-search-bar>
</view>
</view>
<view class="chat-tip" v-if="!initializing && !loading && chatStore.chats.length == 0">
<view class="tip-icon">
<text class="iconfont icon-chat"></text>
</view>
<view class="tip-title">还没有聊天</view>
<view class="tip-content">添加好友或创建群聊,开始精彩的对话吧</view>
</view>
<scroll-view class="scroll-bar" v-else scroll-with-animation="true" scroll-y="true">
<view v-for="(chat, index) in chatStore.chats" :key="index">
<long-press-menu v-if="isShowChat(chat)" :items="menu.items" @select="onSelectMenu($event, index)">
<chat-item :chat="chat" :index="index" :active="menu.chatIdx == index"></chat-item>
</long-press-menu>
</view>
</scroll-view>
</view>
</template>
<script>
export default {
data() {
return {
showSearch: false,
searchText: "",
menu: {
show: false,
style: "",
chatIdx: -1,
isTouchMove: false,
items: [{
key: 'DELETE',
name: '删除该聊天',
icon: 'trash',
color: '#e64e4e'
},
{
key: 'TOP',
name: '置顶该聊天',
icon: 'arrow-up'
}
]
}
}
},
methods: {
onSelectMenu(item, chatIdx) {
switch (item.key) {
case 'DELETE':
this.removeChat(chatIdx);
break;
case 'TOP':
this.moveToTop(chatIdx);
break;
default:
break;
}
this.menu.show = false;
},
removeChat(chatIdx) {
this.chatStore.removeChat(chatIdx);
},
moveToTop(chatIdx) {
this.chatStore.moveTop(chatIdx);
},
isShowChat(chat) {
if (chat.delete) {
return false;
}
return !this.searchText || chat.showName.includes(this.searchText)
},
onSearch() {
this.showSearch = !this.showSearch;
this.searchText = "";
},
refreshUnreadBadge() {
if (this.unreadCount > 0) {
uni.setTabBarBadge({
index: 0,
text: this.unreadCount + ""
})
} else {
uni.removeTabBarBadge({
index: 0,
complete: () => {}
})
}
}
},
computed: {
unreadCount() {
let count = 0;
this.chatStore.chats.forEach(chat => {
if (!chat.isDnd && !chat.delete) {
count += chat.unreadCount;
}
})
return count;
},
loading() {
return this.chatStore.loading;
},
initializing() {
return !this.configStore.appInit;
},
showChats() {
this.chatStore.chats.filter((chat) => !chat.delete && chat.showName && chat.showName.includes(this
.searchText))
}
},
watch: {
unreadCount(newCount, oldCount) {
this.refreshUnreadBadge();
}
},
onShow() {
this.refreshUnreadBadge();
}
}
</script>
<style lang="scss">
.tab-page {
position: relative;
display: flex;
flex-direction: column;
.chat-tip {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
padding: 40rpx;
text-align: center;
width: 80%;
.tip-icon {
width: 120rpx;
height: 120rpx;
background: linear-gradient(135deg, #f8f9fa, #e9ecef);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 40rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
border: 1rpx solid $im-bg-active;
.iconfont {
font-size: 60rpx;
color: $im-text-color-lighter;
}
}
.tip-title {
font-size: $im-font-size-large;
color: $im-text-color;
font-weight: 500;
margin-bottom: 20rpx;
}
.tip-content {
font-size: $im-font-size-smaller;
color: $im-text-color-lighter;
line-height: 1.6;
margin-bottom: 50rpx;
}
}
.chat-loading {
display: block;
width: 100%;
height: 120rpx;
background: white;
color: $im-text-color-lighter;
.loading-box {
position: relative;
}
}
.scroll-bar {
flex: 1;
height: 100%;
}
}
</style>
+252
View File
@@ -0,0 +1,252 @@
<template>
<view class="page user-info">
<nav-bar back>用户信息</nav-bar>
<uni-card :is-shadow="false" is-full :border="false">
<view class="content">
<head-image
:name="userInfo.nickName"
:url="userInfo.headImageThumb"
:size="160"
@click="onShowFullImage()"
></head-image>
<view class="info-item">
<view class="info-primary">
<text class="info-username">{{ userInfo.nickName }}</text>
<text v-show="userInfo.sex == 0" class="iconfont icon-man" color="darkblue"></text>
<text v-show="userInfo.sex == 1" class="iconfont icon-girl" color="darkred"></text>
</view>
<view class="info-text">
<text class="label-text">用户名:</text>
<text class="content-text"> {{ userInfo.userName }}</text>
</view>
<view class="info-text">
<view>
<text class="label-text">签名:</text>
<text class="content-text"> {{ userInfo.signature }} </text>
</view>
</view>
</view>
</view>
</uni-card>
<bar-group v-if="isFriend">
<switch-bar title="消息免打扰" :checked="friendInfo.isDnd" @change="onDndChange"></switch-bar>
</bar-group>
<bar-group v-if="chatIdx >= 0">
<arrow-bar title="清空聊天记录" @tap="onCleanMessage()"></arrow-bar>
</bar-group>
<bar-group>
<btn-bar v-show="isFriend" type="primary" title="发送消息" @tap="onSendMessage()"> </btn-bar>
<btn-bar v-show="!isFriend" type="primary" title="加为好友" @tap="onAddFriend()"></btn-bar>
<btn-bar v-show="isFriend" type="danger" title="删除好友" @tap="onDelFriend()"></btn-bar>
</bar-group>
</view>
</template>
<script>
export default {
data() {
return {
userInfo: {},
};
},
methods: {
onShowFullImage() {
let imageUrl = this.userInfo.headImage;
if (imageUrl) {
uni.previewImage({
urls: [imageUrl],
});
}
},
onSendMessage() {
let chat = {
type: 'PRIVATE',
targetId: this.userInfo.id,
showName: this.userInfo.nickName,
headImage: this.userInfo.headImageThumb,
};
if (this.isFriend) {
chat.isDnd = this.friendInfo.isDnd;
}
this.chatStore.openChat(chat);
let chatIdx = this.chatStore.findChatIdx(chat);
uni.navigateTo({
url: '/pages/message/chat-box?chatIdx=' + chatIdx,
});
},
onAddFriend() {
this.$http({
url: '/friend/add?friendId=' + this.userInfo.id,
method: 'POST',
}).then((data) => {
let friend = {
id: this.userInfo.id,
nickName: this.userInfo.nickName,
headImage: this.userInfo.headImageThumb,
online: this.userInfo.online,
deleted: false,
};
this.friendStore.addFriend(friend);
uni.showToast({
title: '对方已成为您的好友',
icon: 'none',
});
});
},
onDelFriend() {
uni.showModal({
title: '确认删除',
content: `确认删除 '${this.userInfo.nickName}',并删除聊天记录吗?`,
success: (res) => {
if (res.cancel) return;
this.$http({
url: `/friend/delete/${this.userInfo.id}`,
method: 'delete',
}).then((data) => {
this.friendStore.removeFriend(this.userInfo.id);
this.chatStore.removePrivateChat(this.userInfo.id);
uni.showToast({
title: `与 '${this.userInfo.nickName}'的好友关系已解除`,
icon: 'none',
});
});
},
});
},
onCleanMessage() {
uni.showModal({
title: '清空聊天记录',
content: `确认删除与'${this.userInfo.nickName}'的聊天记录吗?`,
confirmText: '确认',
success: (res) => {
if (res.cancel) return;
this.chatStore.cleanMessage(this.chatIdx);
uni.showToast({
title: `您清空了'${this.userInfo.nickName}'的聊天记录`,
icon: 'none',
});
},
});
},
onDndChange(e) {
let isDnd = e.detail.value;
let friendId = this.userInfo.id;
let formData = {
friendId: friendId,
isDnd: isDnd,
};
this.$http({
url: '/friend/dnd',
method: 'PUT',
data: formData,
}).then(() => {
this.friendStore.setDnd(friendId, isDnd);
let chat = this.chatStore.findChatByFriend(friendId);
if (chat) {
this.chatStore.setDnd(chat, isDnd);
}
});
},
updateFriendInfo() {
if (this.isFriend) {
// store的数据不能直接修改,深拷贝一份store的数据
let friend = JSON.parse(JSON.stringify(this.friendInfo));
friend.headImage = this.userInfo.headImageThumb;
friend.nickName = this.userInfo.nickName;
// 更新好友列表中的昵称和头像
this.friendStore.updateFriend(friend);
// 更新会话中的头像和昵称
this.chatStore.updateChatFromFriend(this.userInfo);
}
},
loadUserInfo(id) {
this.$http({
url: '/user/find/' + id,
method: 'GET',
}).then((user) => {
this.userInfo = user;
// 如果发现好友的头像和昵称改了,进行更新
this.updateFriendInfo();
});
},
},
computed: {
isFriend() {
return this.friendStore.isFriend(this.userInfo.id);
},
friendInfo() {
return this.friendStore.findFriend(this.userInfo.id);
},
chatIdx() {
let chat = this.chatStore.findChatByFriend(this.userInfo.id);
if (chat) {
return this.chatStore.findChatIdx(chat);
}
return -1;
},
},
onLoad(options) {
// 查询用户信息
this.loadUserInfo(options.id);
},
};
</script>
<style lang="scss" scoped>
.user-info {
.content {
height: 200rpx;
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx;
.info-item {
display: flex;
align-items: flex-start;
flex-direction: column;
padding-left: 40rpx;
flex: 1;
.info-text {
line-height: 1.5;
//margin-bottom: 10rpx;
}
.label-text {
font-size: $im-font-size-small;
color: $im-text-color-light;
}
.content-text {
font-size: $im-font-size-small;
color: $im-text-color-light;
margin-left: 10rpx;
}
.info-primary {
display: flex;
align-items: center;
margin-bottom: 10rpx;
.info-username {
font-size: $im-font-size-large;
font-weight: 600;
}
.icon-man {
color: $im-text-color;
font-size: $im-font-size-large;
padding-left: 10rpx;
}
.icon-girl {
color: darkred;
}
}
}
}
}
</style>
-65
View File
@@ -1,65 +0,0 @@
<template>
<wd-config-provider theme="dark">
<wd-navbar title="" safeAreaInsetTop style="z-index: 999999 !important" :bordered="false">
<template #left>
<view class="flex items-center text-base">
<text class="mr-4">消息</text>
<text>缘友</text>
</view>
</template>
<template #right>
<navigator url="/pages/" open-type="navigate" hover-class="navigator-hover">
<view class="flex items-center rounded-full h-44rpx bg-color-theme py-1 px-2 text-black">
<uni-icons type="chat" size="20" color="#333" />
<text class="text-sm c-black">互动消息</text>
</view>
</navigator>
</template>
</wd-navbar>
<scroll-view style="height: calc(100vh - 44px - 50px)" scroll-y>
<view
class="flex items-center border-1 border-b-solid border-#333333 px-6 py-2"
v-for="(message, index) in 16"
:key="index"
>
<wd-badge :modelValue="message.unread || 1" :top="10" :right="10">
<image
:src="message.avatar"
class="mr-2 size-120rpx rounded-full bg-#333333"
mode="aspectFill"
/>
</wd-badge>
<view class="flex-1 overflow-hidden px-3">
<view class="flex items-center justify-between">
<text class="text-base">{{ message.username || '发放' }}</text>
<text class="text-xs c-gray">{{ message.time || 'ww' }}</text>
</view>
<view class="flex">
<text class="mt-1 flex-1 text-xs truncate c-gray">
{{ message.lastMessage || '啊发发看来福娃' }}
</text>
</view>
</view>
</view>
<view class="h-100rpx"></view>
</scroll-view>
</wd-config-provider>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
interface Message {
id: number;
username: string;
avatar: string;
lastMessage: string;
time: string;
unread: number;
}
const unreadCount = computed(() => {
return messageList.value.reduce((total, message) => total + message.unread, 0);
});
</script>
-65
View File
@@ -1,65 +0,0 @@
<template>
<wd-config-provider theme="dark">
<wd-navbar title="" safeAreaInsetTop style="z-index: 999999 !important" :bordered="false">
<template #left>
<view class="flex items-center text-base">
<text class="mr-4">消息</text>
<text>缘友</text>
</view>
</template>
<template #right>
<navigator url="/pages/" open-type="navigate" hover-class="navigator-hover">
<view class="flex items-center rounded-full h-44rpx bg-color-theme py-1 px-2 text-black">
<uni-icons type="chat" size="20" color="#333" />
<text class="text-sm c-black">互动消息</text>
</view>
</navigator>
</template>
</wd-navbar>
<scroll-view style="height: calc(100vh - 44px - 50px)" scroll-y>
<view
class="flex items-center border-1 border-b-solid border-#333333 px-6 py-2"
v-for="(message, index) in 16"
:key="index"
>
<wd-badge :modelValue="message.unread || 1" :top="10" :right="10">
<image
:src="message.avatar"
class="mr-2 size-120rpx rounded-full bg-#333333"
mode="aspectFill"
/>
</wd-badge>
<view class="flex-1 overflow-hidden px-3">
<view class="flex items-center justify-between">
<text class="text-base">{{ message.username || '发放' }}</text>
<text class="text-xs c-gray">{{ message.time || 'ww' }}</text>
</view>
<view class="flex">
<text class="mt-1 flex-1 text-xs truncate c-gray">
{{ message.lastMessage || '啊发发看来福娃' }}
</text>
</view>
</view>
</view>
<view class="h-100rpx"></view>
</scroll-view>
</wd-config-provider>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
interface Message {
id: number;
username: string;
avatar: string;
lastMessage: string;
time: string;
unread: number;
}
const unreadCount = computed(() => {
return messageList.value.reduce((total, message) => total + message.unread, 0);
});
</script>
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff. Show more