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