fix
Some checks are pending
Check / lint (push) Waiting to run
Check / typecheck (push) Waiting to run
Check / build (build, 18.x, ubuntu-latest) (push) Waiting to run
Check / build (build, 18.x, windows-latest) (push) Waiting to run
Check / build (build:app, 18.x, ubuntu-latest) (push) Waiting to run
Check / build (build:app, 18.x, windows-latest) (push) Waiting to run
Check / build (build:mp-weixin, 18.x, ubuntu-latest) (push) Waiting to run
Check / build (build:mp-weixin, 18.x, windows-latest) (push) Waiting to run
Some checks are pending
Check / lint (push) Waiting to run
Check / typecheck (push) Waiting to run
Check / build (build, 18.x, ubuntu-latest) (push) Waiting to run
Check / build (build, 18.x, windows-latest) (push) Waiting to run
Check / build (build:app, 18.x, ubuntu-latest) (push) Waiting to run
Check / build (build:app, 18.x, windows-latest) (push) Waiting to run
Check / build (build:mp-weixin, 18.x, ubuntu-latest) (push) Waiting to run
Check / build (build:mp-weixin, 18.x, windows-latest) (push) Waiting to run
This commit is contained in:
parent
fd060743bf
commit
2464c15603
@ -31,7 +31,9 @@
|
||||
"@vueuse/core": "^9.13.0",
|
||||
"axios": "^1.7.2",
|
||||
"dayjs": "^1.11.12",
|
||||
"less": "^4.2.0",
|
||||
"nzh": "^1.0.13",
|
||||
"pinia-plugin-persistedstate": "^4.1.3",
|
||||
"vconsole": "^3.15.1",
|
||||
"vue": "^3.3.8",
|
||||
"vue-i18n": "^9.6.5"
|
||||
|
1050
pnpm-lock.yaml
1050
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -1,10 +1,17 @@
|
||||
<script setup>
|
||||
import {useStatus} from "@/store/status";
|
||||
import { useUserStore } from '@/store'
|
||||
import {
|
||||
useProvideUserModal,
|
||||
} from '@/hooks'
|
||||
import ws from '@/connect'
|
||||
const {statusBarHeight}= useStatus()
|
||||
const { uid, isShow } = useProvideUserModal()
|
||||
const userStore = useUserStore()
|
||||
const root = document.documentElement
|
||||
root.style.setProperty('--statusBarHeight',`${statusBarHeight.value}px`)
|
||||
const init = () => {
|
||||
userStore.loadSetting()
|
||||
ws.connect()
|
||||
}
|
||||
init()
|
||||
|
21
src/api/upload/index.js
Normal file
21
src/api/upload/index.js
Normal file
@ -0,0 +1,21 @@
|
||||
import { post, upload } from '@/utils/upload/request'
|
||||
|
||||
// 上传头像裁剪图片服务接口
|
||||
export const ServeUploadAvatar = (data) => {
|
||||
return post('/api/v1/upload/avatar', data)
|
||||
}
|
||||
|
||||
// 上传头像裁剪图片服务接口
|
||||
export const ServeUploadImage = (data) => {
|
||||
return post('/api/v1/upload/image', data)
|
||||
}
|
||||
|
||||
// 查询大文件拆分信息服务接口
|
||||
export const ServeFindFileSplitInfo = (data = {}) => {
|
||||
return post('/api/v1/upload/multipart/initiate', data)
|
||||
}
|
||||
|
||||
// 文件拆分上传服务接口
|
||||
export const ServeFileSubareaUpload = (data = {}, options = {}) => {
|
||||
return upload('/api/v1/upload/multipart', data, options)
|
||||
}
|
9
src/api/user/index.js
Normal file
9
src/api/user/index.js
Normal file
@ -0,0 +1,9 @@
|
||||
import request from '@/service/index.js'
|
||||
|
||||
export const ServeGetUserSetting = (data) => {
|
||||
return request({
|
||||
url: '/api/v1/users/setting',
|
||||
method: 'GET',
|
||||
data,
|
||||
})
|
||||
}
|
41
src/components/base/Avatar.vue
Normal file
41
src/components/base/Avatar.vue
Normal file
@ -0,0 +1,41 @@
|
||||
<script lang="ts" setup>
|
||||
import { hashStrToHexColor } from '@/utils/common'
|
||||
import { defAvatar } from '@/constant/default'
|
||||
|
||||
defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
username: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: 30
|
||||
},
|
||||
fontSize: {
|
||||
type: Number,
|
||||
default: 14
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-avatar v-if="src.length" round :src="src" :size="size" :fallback-src="defAvatar" />
|
||||
|
||||
<n-avatar
|
||||
v-else
|
||||
round
|
||||
:style="{
|
||||
color: '#ffffff',
|
||||
backgroundColor: hashStrToHexColor(username || ''),
|
||||
fontSize: fontSize + 'px'
|
||||
}"
|
||||
:size="size"
|
||||
>
|
||||
{{ username && username.substring(0, 1) }}
|
||||
</n-avatar>
|
||||
</template>
|
||||
<style lang="less" scoped></style>
|
225
src/components/base/AvatarCropper.vue
Normal file
225
src/components/base/AvatarCropper.vue
Normal file
@ -0,0 +1,225 @@
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { NModal, NCard } from 'naive-ui'
|
||||
import { Close, UploadOne, RefreshOne, Redo, Undo } from '@icon-park/vue-next'
|
||||
import 'vue-cropper/dist/index.css'
|
||||
import { VueCropper } from 'vue-cropper'
|
||||
import { ServeUploadAvatar } from '@/api/upload'
|
||||
|
||||
const emit = defineEmits(['close', 'success'])
|
||||
const state = reactive({
|
||||
show: true,
|
||||
src: ''
|
||||
})
|
||||
|
||||
const cropper = ref('cropper')
|
||||
|
||||
const option = reactive({
|
||||
img: '',
|
||||
size: 1,
|
||||
full: false,
|
||||
outputType: 'png',
|
||||
canMove: true,
|
||||
fixedBox: true,
|
||||
original: false,
|
||||
canMoveBox: true,
|
||||
autoCrop: true,
|
||||
autoCropWidth: 250,
|
||||
autoCropHeight: 250,
|
||||
centerBox: false,
|
||||
high: true,
|
||||
preview: ''
|
||||
})
|
||||
|
||||
const onMaskClick = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onTriggerUpload() {
|
||||
document.getElementById('upload-avatar').click()
|
||||
}
|
||||
|
||||
const onUpload = (e) => {
|
||||
let file = e.target.files[0]
|
||||
|
||||
let reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
let data
|
||||
if (typeof e.target.result === 'object') {
|
||||
// 把Array Buffer转化为blob 如果是base64不需要
|
||||
data = window.URL.createObjectURL(new Blob([e.target.result]))
|
||||
|
||||
console.log(data, e.target.result)
|
||||
} else {
|
||||
data = e.target.result
|
||||
}
|
||||
|
||||
option.img = data
|
||||
}
|
||||
|
||||
reader.readAsArrayBuffer(file)
|
||||
}
|
||||
|
||||
const realTime = (data) => {
|
||||
cropper.value.getCropData((img) => {
|
||||
option.preview = img
|
||||
})
|
||||
}
|
||||
|
||||
const rotateLeft = () => {
|
||||
cropper.value.rotateLeft()
|
||||
}
|
||||
const rotateRight = () => {
|
||||
cropper.value.rotateRight()
|
||||
}
|
||||
|
||||
const refreshCrop = () => {
|
||||
cropper.value.refresh()
|
||||
}
|
||||
|
||||
const onSubmit = () => {
|
||||
cropper.value.getCropBlob((blob) => {
|
||||
let file = new File([blob], 'avatar.png', {
|
||||
type: blob.type,
|
||||
lastModified: Date.now()
|
||||
})
|
||||
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
|
||||
ServeUploadAvatar(form).then((res) => {
|
||||
if (res.code == 200) {
|
||||
emit('success', res.data.avatar)
|
||||
} else {
|
||||
window['$message'].info(res.message)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
id="upload-avatar"
|
||||
type="file"
|
||||
accept="image/png, image/jpeg, image/jpg, image/webp"
|
||||
@change="onUpload"
|
||||
/>
|
||||
<n-modal v-model:show="state.show" :on-after-leave="onMaskClick">
|
||||
<n-card style="width: 800px" title="选择头像" :bordered="false" class="modal-radius">
|
||||
<template #header-extra>
|
||||
<n-icon size="22" :component="Close" @click="state.show = false" class="pointer" />
|
||||
</template>
|
||||
|
||||
<div class="content">
|
||||
<div class="canvas">
|
||||
<vue-cropper
|
||||
ref="cropper"
|
||||
:img="option.img"
|
||||
:output-size="option.size"
|
||||
:output-type="option.outputType"
|
||||
:info="true"
|
||||
:full="option.full"
|
||||
:can-move="option.canMove"
|
||||
:can-move-box="option.canMoveBox"
|
||||
:fixed-box="option.fixedBox"
|
||||
:original="option.original"
|
||||
:auto-crop="option.autoCrop"
|
||||
:auto-crop-width="option.autoCropWidth"
|
||||
:auto-crop-height="option.autoCropHeight"
|
||||
:center-box="option.centerBox"
|
||||
@real-time="realTime"
|
||||
/>
|
||||
</div>
|
||||
<div class="view">
|
||||
<div class="preview">
|
||||
<img :src="option.preview" v-show="option.preview" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<section class="el-container" style="height: 38px">
|
||||
<aside
|
||||
class="el-aside"
|
||||
style="
|
||||
width: 400px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
padding: 0 5px;
|
||||
"
|
||||
>
|
||||
<n-button @click="onTriggerUpload" type="primary" ghost>
|
||||
上传图片
|
||||
<template #icon>
|
||||
<n-icon :component="UploadOne" />
|
||||
</template>
|
||||
</n-button>
|
||||
|
||||
<n-button @click="refreshCrop">
|
||||
重置
|
||||
<template #icon>
|
||||
<n-icon :component="RefreshOne" />
|
||||
</template>
|
||||
</n-button>
|
||||
|
||||
<n-button @click="rotateLeft">
|
||||
<template #icon>
|
||||
<n-icon :component="Undo" />
|
||||
</template>
|
||||
左转
|
||||
</n-button>
|
||||
|
||||
<n-button @click="rotateRight">
|
||||
<template #icon>
|
||||
<n-icon :component="Redo" />
|
||||
</template>
|
||||
右转
|
||||
</n-button>
|
||||
</aside>
|
||||
<main class="el-main" style="text-align: center">
|
||||
<n-button type="primary" @click="onSubmit">保存头像</n-button>
|
||||
</main>
|
||||
</section>
|
||||
</template>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
#upload-avatar {
|
||||
display: none;
|
||||
}
|
||||
.content {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
display: flex;
|
||||
|
||||
.canvas {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.view {
|
||||
flex: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.preview {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
305
src/components/base/Loading.vue
Normal file
305
src/components/base/Loading.vue
Normal file
@ -0,0 +1,305 @@
|
||||
<script setup></script>
|
||||
|
||||
<template>
|
||||
<div class="loading-content">
|
||||
<div class="ant-spin ant-spin-lg ant-spin-spinning">
|
||||
<span class="ant-spin-dot ant-spin-dot-spin">
|
||||
<i class="ant-spin-dot-item" />
|
||||
<i class="ant-spin-dot-item" />
|
||||
<i class="ant-spin-dot-item" />
|
||||
<i class="ant-spin-dot-item" />
|
||||
</span>
|
||||
</div>
|
||||
<p>数据加载中...</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.loading-content {
|
||||
width: 100%;
|
||||
height: 60%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
p {
|
||||
margin-top: 10px;
|
||||
color: rgb(194 194 194);
|
||||
}
|
||||
}
|
||||
|
||||
/* ant-spin 加载动画 start */
|
||||
.ant-spin {
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
font-size: 14px;
|
||||
font-variant: tabular-nums;
|
||||
line-height: 1.5715;
|
||||
list-style: none;
|
||||
-webkit-font-feature-settings: 'tnum';
|
||||
font-feature-settings: 'tnum';
|
||||
position: absolute;
|
||||
display: none;
|
||||
color: #1890ff;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
opacity: 0;
|
||||
-webkit-transition: -webkit-transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
|
||||
transition: -webkit-transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
|
||||
transition: transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
|
||||
transition:
|
||||
transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86),
|
||||
-webkit-transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
|
||||
}
|
||||
|
||||
.ant-spin-spinning {
|
||||
position: static;
|
||||
display: inline-block;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ant-spin-nested-loading {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ant-spin-nested-loading > div > .ant-spin {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 4;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.ant-spin-nested-loading > div > .ant-spin .ant-spin-dot {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin: -10px;
|
||||
}
|
||||
|
||||
.ant-spin-nested-loading > div > .ant-spin .ant-spin-text {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 100%;
|
||||
padding-top: 5px;
|
||||
text-shadow: 0 1px 2px #fff;
|
||||
}
|
||||
|
||||
.ant-spin-nested-loading > div > .ant-spin.ant-spin-show-text .ant-spin-dot {
|
||||
margin-top: -20px;
|
||||
}
|
||||
|
||||
.ant-spin-nested-loading > div > .ant-spin-sm .ant-spin-dot {
|
||||
margin: -7px;
|
||||
}
|
||||
|
||||
.ant-spin-nested-loading > div > .ant-spin-sm .ant-spin-text {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.ant-spin-nested-loading > div > .ant-spin-sm.ant-spin-show-text .ant-spin-dot {
|
||||
margin-top: -17px;
|
||||
}
|
||||
|
||||
.ant-spin-nested-loading > div > .ant-spin-lg .ant-spin-dot {
|
||||
margin: -16px;
|
||||
}
|
||||
|
||||
.ant-spin-nested-loading > div > .ant-spin-lg .ant-spin-text {
|
||||
padding-top: 11px;
|
||||
}
|
||||
|
||||
.ant-spin-nested-loading > div > .ant-spin-lg.ant-spin-show-text .ant-spin-dot {
|
||||
margin-top: -26px;
|
||||
}
|
||||
|
||||
.ant-spin-container {
|
||||
position: relative;
|
||||
-webkit-transition: opacity 0.3s;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.ant-spin-container::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
display: none \9;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
opacity: 0;
|
||||
-webkit-transition: all 0.3s;
|
||||
transition: all 0.3s;
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ant-spin-blur {
|
||||
clear: both;
|
||||
overflow: hidden;
|
||||
opacity: 0.5;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ant-spin-blur::after {
|
||||
opacity: 0.4;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.ant-spin-tip {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.ant-spin-dot {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
font-size: 20px;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.ant-spin-dot-item {
|
||||
position: absolute;
|
||||
display: block;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
background-color: #1890ff;
|
||||
border-radius: 100%;
|
||||
-webkit-transform: scale(0.75);
|
||||
transform: scale(0.75);
|
||||
-webkit-transform-origin: 50% 50%;
|
||||
transform-origin: 50% 50%;
|
||||
opacity: 0.3;
|
||||
-webkit-animation: antSpinMove 1s infinite linear alternate;
|
||||
animation: antSpinMove 1s infinite linear alternate;
|
||||
}
|
||||
|
||||
.ant-spin-dot-item:nth-child(1) {
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.ant-spin-dot-item:nth-child(2) {
|
||||
top: 0;
|
||||
right: 0;
|
||||
-webkit-animation-delay: 0.4s;
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
.ant-spin-dot-item:nth-child(3) {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
-webkit-animation-delay: 0.8s;
|
||||
animation-delay: 0.8s;
|
||||
}
|
||||
|
||||
.ant-spin-dot-item:nth-child(4) {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
-webkit-animation-delay: 1.2s;
|
||||
animation-delay: 1.2s;
|
||||
}
|
||||
|
||||
.ant-spin-dot-spin {
|
||||
-webkit-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
-webkit-animation: antRotate 1.2s infinite linear;
|
||||
animation: antRotate 1.2s infinite linear;
|
||||
}
|
||||
|
||||
.ant-spin-sm .ant-spin-dot {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ant-spin-sm .ant-spin-dot i {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.ant-spin-lg .ant-spin-dot {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.ant-spin-lg .ant-spin-dot i {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.ant-spin.ant-spin-show-text .ant-spin-text {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
|
||||
.ant-spin-blur {
|
||||
background: #fff;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@-webkit-keyframes antSpinMove {
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes antSpinMove {
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@-webkit-keyframes antRotate {
|
||||
to {
|
||||
-webkit-transform: rotate(405deg);
|
||||
transform: rotate(405deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes antRotate {
|
||||
to {
|
||||
-webkit-transform: rotate(405deg);
|
||||
transform: rotate(405deg);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-spin-rtl {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.ant-spin-rtl .ant-spin-dot-spin {
|
||||
-webkit-transform: rotate(-45deg);
|
||||
transform: rotate(-45deg);
|
||||
-webkit-animation-name: antRotateRtl;
|
||||
animation-name: antRotateRtl;
|
||||
}
|
||||
|
||||
@-webkit-keyframes antRotateRtl {
|
||||
to {
|
||||
-webkit-transform: rotate(-405deg);
|
||||
transform: rotate(-405deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes antRotateRtl {
|
||||
to {
|
||||
-webkit-transform: rotate(-405deg);
|
||||
transform: rotate(-405deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ant-spin 加载动画 end */
|
||||
</style>
|
164
src/components/base/UploadsModal.vue
Normal file
164
src/components/base/UploadsModal.vue
Normal file
@ -0,0 +1,164 @@
|
||||
<script setup>
|
||||
import { NProgress } from 'naive-ui'
|
||||
import { useUploadsStore } from '@/store'
|
||||
import { fileFormatSize } from '@/utils/strings'
|
||||
|
||||
const uploadsStore = useUploadsStore()
|
||||
|
||||
const statusItem = {
|
||||
0: '等待上传',
|
||||
1: '上传中',
|
||||
2: '上传完成',
|
||||
3: '网络异常'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section me-scrollbar me-scrollbar-thumb">
|
||||
<div class="title bdr-b">
|
||||
<span>上传管理 ({{ uploadsStore.successCount }}/{{ uploadsStore.items.length }})</span>
|
||||
<span class="pointer" @click="uploadsStore.close()">关闭</span>
|
||||
</div>
|
||||
|
||||
<div class="file-item" v-for="item in uploadsStore.items" :key="item.upload_id">
|
||||
<div class="file-header">
|
||||
<div class="type-icon flex-center">
|
||||
{{ item.username.substr(0, 1) }}
|
||||
</div>
|
||||
<div class="filename">{{ item.username }}</div>
|
||||
<div class="status">
|
||||
<span :class="{ success: item.status == 2 }">
|
||||
{{ statusItem[item.status] }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-mian">
|
||||
<div class="progress flex-center">
|
||||
<n-progress
|
||||
style="width: 60px; height: 60px"
|
||||
type="circle"
|
||||
:percentage="item.percentage"
|
||||
/>
|
||||
</div>
|
||||
<div class="detail">
|
||||
<p>
|
||||
名称:<span>{{ item.file.name }}</span>
|
||||
</p>
|
||||
<p>
|
||||
类型:<span>{{ item.file.type || 'text' }}</span>
|
||||
</p>
|
||||
<p>
|
||||
大小:<span>{{ fileFormatSize(item.file.size) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.section {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
.title {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
width: 95%;
|
||||
min-height: 100px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 15px auto;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--im-message-border-color);
|
||||
border-radius: 5px;
|
||||
|
||||
.file-header {
|
||||
height: 45px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
border-bottom: 1px solid var(--im-message-border-color);
|
||||
|
||||
.type-icon {
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
background-color: rgb(80, 138, 254);
|
||||
border-radius: 50%;
|
||||
margin-left: 5px;
|
||||
font-size: 10px;
|
||||
font-weight: 200;
|
||||
overflow: hidden;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.filename {
|
||||
margin-left: 10px;
|
||||
font-size: 14px;
|
||||
width: 65%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
top: 12px;
|
||||
font-size: 13px;
|
||||
color: #6b6868;
|
||||
font-weight: 200;
|
||||
|
||||
.success {
|
||||
color: rgb(103, 194, 58);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.file-mian {
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.progress {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail {
|
||||
flex: auto;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: 10px;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
font-size: 13px;
|
||||
|
||||
p {
|
||||
margin: 3px;
|
||||
color: #ada8a8;
|
||||
|
||||
span {
|
||||
color: #595a5a;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.n-progress-text) {
|
||||
font-size: 13px !important;
|
||||
}
|
||||
</style>
|
41
src/components/base/Xtime.vue
Normal file
41
src/components/base/Xtime.vue
Normal file
@ -0,0 +1,41 @@
|
||||
<script>
|
||||
import { defineComponent, h, ref, onUnmounted, watch } from 'vue'
|
||||
import { beautifyTime } from '@/utils/datetime'
|
||||
|
||||
// 时间组件
|
||||
export default defineComponent({
|
||||
name: 'Xtime',
|
||||
props: {
|
||||
time: {
|
||||
type: String,
|
||||
default: '2022-03-06 21:20:00'
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
let timeout = null
|
||||
|
||||
const inTime = new Date(props.time.replace(/-/g, '/')).getTime()
|
||||
|
||||
const text = ref('')
|
||||
|
||||
const format = () => {
|
||||
clearTimeout(timeout)
|
||||
|
||||
text.value = beautifyTime(props.time)
|
||||
if (new Date().getTime() - inTime < 30 * 60 * 1000) {
|
||||
timeout = setTimeout(format, 60 * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
watch(props, format)
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimeout(timeout)
|
||||
})
|
||||
|
||||
format()
|
||||
|
||||
return () => h('span', [text.value])
|
||||
}
|
||||
})
|
||||
</script>
|
124
src/components/talk/ForwardRecord.vue
Normal file
124
src/components/talk/ForwardRecord.vue
Normal file
@ -0,0 +1,124 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import Loading from '@/components/base/Loading.vue'
|
||||
import { ServeGetForwardRecords } from '@/api/chat'
|
||||
import { MessageComponents } from '@/constant/message'
|
||||
import { ITalkRecord } from '@/types/chat'
|
||||
import { useInject } from '@/hooks'
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
const props = defineProps({
|
||||
msgId: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const { showUserInfoModal } = useInject()
|
||||
const isShow = ref(true)
|
||||
const items = ref<ITalkRecord[]>([])
|
||||
const title = ref('会话记录')
|
||||
|
||||
const onMaskClick = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const onLoadData = () => {
|
||||
ServeGetForwardRecords({
|
||||
msg_id: props.msgId
|
||||
}).then((res) => {
|
||||
if (res.code == 200) {
|
||||
items.value = res.data.items || []
|
||||
|
||||
title.value = `会话记录(${items.value.length})`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onLoadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="isShow"
|
||||
preset="card"
|
||||
:title="title"
|
||||
style="max-width: 500px"
|
||||
class="modal-radius"
|
||||
:on-after-leave="onMaskClick"
|
||||
:segmented="{
|
||||
content: true
|
||||
}"
|
||||
:header-style="{
|
||||
padding: '20px 15px'
|
||||
}"
|
||||
:content-style="{
|
||||
padding: 0
|
||||
}"
|
||||
>
|
||||
<div class="main-box me-scrollbar me-scrollbar-thumb">
|
||||
<Loading v-if="items.length === 0" />
|
||||
|
||||
<div v-for="item in items" :key="item.msg_id" class="message-item">
|
||||
<div class="left-box pointer" @click="showUserInfoModal(item.user_id)">
|
||||
<im-avatar :src="item.avatar" :size="30" :username="item.nickname" />
|
||||
</div>
|
||||
|
||||
<div class="right-box">
|
||||
<div class="msg-header">
|
||||
<span class="name">{{ item.nickname }}</span>
|
||||
<span class="time"> {{ item.created_at }}</span>
|
||||
</div>
|
||||
|
||||
<component
|
||||
:is="MessageComponents[item.msg_type] || 'unknown-message'"
|
||||
:extra="item.extra"
|
||||
:data="item"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.main-box {
|
||||
height: 600px;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
padding: 5px 15px;
|
||||
|
||||
.left-box {
|
||||
width: 30px;
|
||||
display: flex;
|
||||
user-select: none;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.right-box {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
padding: 0px 5px 15px 5px;
|
||||
box-sizing: border-box;
|
||||
height: fit-content;
|
||||
|
||||
.msg-header {
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
font-size: 12px;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
281
src/components/talk/HistoryRecord.vue
Normal file
281
src/components/talk/HistoryRecord.vue
Normal file
@ -0,0 +1,281 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import Loading from '@/components/base/Loading.vue'
|
||||
import { ServeFindTalkRecords } from '@/api/chat'
|
||||
import { Down, Calendar } from '@icon-park/vue-next'
|
||||
import * as message from '@/constant/message'
|
||||
import { ITalkRecord } from '@/types/chat'
|
||||
import { useInject } from '@/hooks'
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
const props = defineProps({
|
||||
talkType: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
receiverId: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
const { showUserInfoModal } = useInject()
|
||||
const model = reactive({
|
||||
cursor: 0,
|
||||
limit: 30,
|
||||
msgType: 0,
|
||||
loading: false,
|
||||
loadMore: false,
|
||||
isLoadMore: true
|
||||
})
|
||||
|
||||
const isShow = ref(true)
|
||||
const items = ref<ITalkRecord[]>([])
|
||||
|
||||
const tabs = [
|
||||
{ name: '全部', type: 0, show: true },
|
||||
{ name: '图片', type: message.ChatMsgTypeImage, show: true },
|
||||
{ name: '音频', type: message.ChatMsgTypeAudio, show: true },
|
||||
{ name: '视频', type: message.ChatMsgTypeVideo, show: true },
|
||||
{ name: '文件', type: message.ChatMsgTypeFile, show: true },
|
||||
{ name: '会话', type: message.ChatMsgTypeForward, show: true },
|
||||
{ name: '代码', type: message.ChatMsgTypeCode, show: true },
|
||||
{ name: '位置', type: message.ChatMsgTypeLocation, show: true },
|
||||
{ name: '群投票', type: message.ChatMsgTypeVote, show: props.talkType == 2 }
|
||||
]
|
||||
|
||||
const onMaskClick = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const loadChatRecord = () => {
|
||||
let data = {
|
||||
talk_type: props.talkType,
|
||||
receiver_id: props.receiverId,
|
||||
msg_type: model.msgType,
|
||||
cursor: model.cursor,
|
||||
limit: model.limit
|
||||
}
|
||||
|
||||
if (model.cursor === 0) {
|
||||
model.loading = true
|
||||
} else {
|
||||
model.loadMore = true
|
||||
}
|
||||
|
||||
ServeFindTalkRecords(data).then((res) => {
|
||||
if (res.code != 200) return
|
||||
|
||||
if (data.cursor === 0) {
|
||||
items.value = []
|
||||
}
|
||||
|
||||
let list = res.data.items || []
|
||||
if (list.length) {
|
||||
model.cursor = res.data.cursor
|
||||
}
|
||||
|
||||
model.loading = false
|
||||
model.loadMore = false
|
||||
model.isLoadMore = list.length >= model.limit
|
||||
items.value.push(...list)
|
||||
})
|
||||
}
|
||||
|
||||
const triggerType = (type: number) => {
|
||||
model.msgType = type
|
||||
model.cursor = 0
|
||||
loadChatRecord()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadChatRecord()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="isShow"
|
||||
preset="card"
|
||||
title="消息管理"
|
||||
style="max-width: 750px"
|
||||
class="modal-radius"
|
||||
:on-after-leave="onMaskClick"
|
||||
:segmented="{
|
||||
content: true
|
||||
}"
|
||||
:header-style="{
|
||||
padding: '20px 15px'
|
||||
}"
|
||||
:content-style="{
|
||||
padding: 0
|
||||
}"
|
||||
>
|
||||
<section class="main-box el-container is-vertical o-hidden">
|
||||
<header class="el-header bdr-b search" style="height: 50px">
|
||||
<div class="type-items">
|
||||
<span
|
||||
v-for="tab in tabs"
|
||||
:key="tab.name"
|
||||
class="pointer"
|
||||
:class="{ active: model.msgType == tab.type }"
|
||||
@click="triggerType(tab.type)"
|
||||
v-show="tab.show"
|
||||
>
|
||||
{{ tab.name }}
|
||||
</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center">
|
||||
<!-- <n-popover placement="bottom-end" trigger="click" :show-arrow="false">
|
||||
<template #trigger>
|
||||
<n-icon
|
||||
:size="20"
|
||||
class="pointer"
|
||||
:component="Calendar"
|
||||
/>
|
||||
</template>
|
||||
<n-date-picker
|
||||
panel
|
||||
type="date"
|
||||
:is-date-disabled="disablePreviousDate"
|
||||
:on-update:value="datefunc"
|
||||
/>
|
||||
</n-popover> -->
|
||||
|
||||
<n-icon :size="20" class="pointer" :component="Calendar" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main v-if="model.loading" class="el-main flex-center">
|
||||
<Loading />
|
||||
</main>
|
||||
|
||||
<main v-else-if="items.length === 0" class="el-main flex-center">
|
||||
<n-empty size="200" description="暂无相关数据">
|
||||
<template #icon>
|
||||
<img src="@/assets/image/no-data.svg" alt="" />
|
||||
</template>
|
||||
</n-empty>
|
||||
</main>
|
||||
|
||||
<main v-else class="el-main me-scrollbar me-scrollbar-thumb">
|
||||
<div v-for="item in items" :key="item.id" class="message-item">
|
||||
<div class="left-box">
|
||||
<im-avatar
|
||||
:src="item.avatar"
|
||||
:size="30"
|
||||
:username="item.nickname"
|
||||
@click="showUserInfoModal(item.user_id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="right-box me-scrollbar">
|
||||
<div class="msg-header">
|
||||
<span class="name">{{ item.nickname }}</span>
|
||||
<span class="time"> {{ item.created_at }}</span>
|
||||
</div>
|
||||
|
||||
<template v-if="item.is_revoke == 1">
|
||||
<div class="msg-content">此消息已被撤回</div>
|
||||
</template>
|
||||
|
||||
<component
|
||||
v-if="item.is_revoke == 0"
|
||||
:is="message.MessageComponents[item.msg_type] || 'unknown-message'"
|
||||
:extra="item.extra"
|
||||
:data="item"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="more pointer flex-center" @click="loadChatRecord" v-show="model.isLoadMore">
|
||||
<n-icon v-show="!model.loadMore" :size="20" class="icon" :component="Down" />
|
||||
<span> {{ model.loadMore ? '数据加载中...' : '加载更多' }} </span>
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.main-box {
|
||||
height: 550px;
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 15px 0 5px;
|
||||
|
||||
.type-items {
|
||||
line-height: 40px;
|
||||
user-select: none;
|
||||
|
||||
.active {
|
||||
color: #03a9f4;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
span {
|
||||
height: 40px;
|
||||
width: 45px;
|
||||
margin: 0 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-item {
|
||||
min-height: 30px;
|
||||
display: flex;
|
||||
margin-bottom: 5px;
|
||||
flex-direction: row;
|
||||
padding: 5px 15px;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.left-box {
|
||||
width: 30px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
padding-top: 8px;
|
||||
margin-right: 10px;
|
||||
|
||||
img {
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.right-box {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
padding: 0px 5px 15px 5px;
|
||||
box-sizing: border-box;
|
||||
height: fit-content;
|
||||
|
||||
.msg-header {
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
font-size: 12px;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.more {
|
||||
margin: 10px auto 20px;
|
||||
width: 150px;
|
||||
height: 30px;
|
||||
}
|
||||
</style>
|
260
src/components/talk/message/AudioMessage.vue
Normal file
260
src/components/talk/message/AudioMessage.vue
Normal file
@ -0,0 +1,260 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { PlayOne, PauseOne } from '@icon-park/vue-next'
|
||||
import { ITalkRecordExtraAudio, ITalkRecord } from '@/types/chat'
|
||||
|
||||
defineProps<{
|
||||
extra: ITalkRecordExtraAudio
|
||||
data: ITalkRecord
|
||||
maxWidth?: Boolean
|
||||
}>()
|
||||
|
||||
const audioRef = ref()
|
||||
|
||||
const durationDesc = ref('-')
|
||||
|
||||
const state = reactive({
|
||||
isAudioPlay: false,
|
||||
progress: 0,
|
||||
duration: 0,
|
||||
currentTime: 0,
|
||||
loading: true
|
||||
})
|
||||
|
||||
const onPlay = () => {
|
||||
if (state.isAudioPlay) {
|
||||
audioRef.value.pause()
|
||||
} else {
|
||||
audioRef.value.play()
|
||||
}
|
||||
|
||||
state.isAudioPlay = !state.isAudioPlay
|
||||
}
|
||||
|
||||
const onPlayEnd = () => {
|
||||
state.isAudioPlay = false
|
||||
state.progress = 0
|
||||
}
|
||||
|
||||
const onCanplay = () => {
|
||||
state.duration = audioRef.value.duration
|
||||
durationDesc.value = formatTime(parseInt(audioRef.value.duration))
|
||||
state.loading = false
|
||||
}
|
||||
|
||||
const onError = (e: any) => {
|
||||
console.log('音频播放异常===>', e)
|
||||
}
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
let audio = audioRef.value
|
||||
if (audio.duration == 0) {
|
||||
state.progress = 0
|
||||
} else {
|
||||
state.currentTime = audio.currentTime
|
||||
state.progress = (audio.currentTime / audio.duration) * 100
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (value: number = 0) => {
|
||||
if (value == 0) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const minutes = Math.floor(value / 60)
|
||||
let seconds = value
|
||||
if (minutes > 0) {
|
||||
seconds = Math.floor(value - minutes * 60)
|
||||
}
|
||||
|
||||
return `${minutes}'${seconds}"`
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="im-message-audio">
|
||||
<audio
|
||||
ref="audioRef"
|
||||
preload="auto"
|
||||
type="audio/mp3,audio/wav"
|
||||
:src="extra.url"
|
||||
@timeupdate="onTimeUpdate"
|
||||
@ended="onPlayEnd"
|
||||
@canplay="onCanplay"
|
||||
@error="onError"
|
||||
/>
|
||||
|
||||
<div class="play">
|
||||
<div class="btn pointer" @click.stop="onPlay">
|
||||
<n-icon :size="18" :component="state.isAudioPlay ? PauseOne : PlayOne" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="desc">
|
||||
<span class="line" v-for="i in 23" :key="i"></span>
|
||||
<span
|
||||
class="indicator"
|
||||
:style="{ left: state.progress + '%' }"
|
||||
v-show="state.progress > 0"
|
||||
></span>
|
||||
</div>
|
||||
<div class="time">{{ durationDesc }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="less" scoped>
|
||||
.im-message-audio {
|
||||
--audio-bg-color: #f5f5f5;
|
||||
--audio-btn-bg-color: #ffffff;
|
||||
|
||||
width: 200px;
|
||||
height: 45px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
background-color: var(--audio-bg-color);
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.play {
|
||||
width: 45px;
|
||||
height: inherit;
|
||||
flex-shrink: 0;
|
||||
|
||||
.btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
background-color: var(--audio-btn-bg-color);
|
||||
border-radius: 50%;
|
||||
color: rgb(24, 24, 24);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.desc {
|
||||
flex: 1 1;
|
||||
height: inherit;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
.line {
|
||||
justify-content: space-between;
|
||||
height: 30px;
|
||||
width: 2px;
|
||||
background-color: rgb(40, 39, 39);
|
||||
margin-left: 3px;
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&:nth-child(1) {
|
||||
height: 16px;
|
||||
}
|
||||
&:nth-child(2) {
|
||||
height: 10px;
|
||||
}
|
||||
&:nth-child(3) {
|
||||
height: 8px;
|
||||
}
|
||||
&:nth-child(4) {
|
||||
height: 6px;
|
||||
}
|
||||
&:nth-child(5) {
|
||||
height: 2px;
|
||||
}
|
||||
&:nth-child(6) {
|
||||
height: 10px;
|
||||
}
|
||||
&:nth-child(7) {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
&:nth-child(8) {
|
||||
height: 16px;
|
||||
}
|
||||
&:nth-child(9) {
|
||||
height: 10px;
|
||||
}
|
||||
&:nth-child(10) {
|
||||
height: 13px;
|
||||
}
|
||||
&:nth-child(11) {
|
||||
height: 10px;
|
||||
}
|
||||
&:nth-child(12) {
|
||||
height: 8px;
|
||||
}
|
||||
&:nth-child(13) {
|
||||
height: 15px;
|
||||
}
|
||||
&:nth-child(14) {
|
||||
height: 16px;
|
||||
}
|
||||
&:nth-child(15) {
|
||||
height: 16px;
|
||||
}
|
||||
&:nth-child(16) {
|
||||
height: 15px;
|
||||
}
|
||||
&:nth-child(17) {
|
||||
height: 14px;
|
||||
}
|
||||
&:nth-child(18) {
|
||||
height: 12px;
|
||||
}
|
||||
&:nth-child(19) {
|
||||
height: 8px;
|
||||
}
|
||||
&:nth-child(20) {
|
||||
height: 3px;
|
||||
}
|
||||
&:nth-child(21) {
|
||||
height: 6px;
|
||||
}
|
||||
&:nth-child(22) {
|
||||
height: 10px;
|
||||
}
|
||||
&:nth-child(23) {
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.indicator {
|
||||
position: absolute;
|
||||
height: 70%;
|
||||
width: 1px;
|
||||
background-color: #9b9595;
|
||||
}
|
||||
}
|
||||
|
||||
.time {
|
||||
width: 50px;
|
||||
height: inherit;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
html[theme-mode='dark'] {
|
||||
.im-message-audio {
|
||||
--audio-bg-color: #2c2c32;
|
||||
--audio-btn-bg-color: rgb(78, 75, 75);
|
||||
|
||||
.btn {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.desc {
|
||||
.line {
|
||||
background-color: rgb(169, 167, 167);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
127
src/components/talk/message/CodeMessage.vue
Normal file
127
src/components/talk/message/CodeMessage.vue
Normal file
@ -0,0 +1,127 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { NCode } from 'naive-ui'
|
||||
import { Copy, Stretching } from '@icon-park/vue-next'
|
||||
import { clipboard } from '@/utils/common'
|
||||
import { useUtil } from '@/hooks'
|
||||
import { ITalkRecordExtraCode, ITalkRecord } from '@/types/chat'
|
||||
|
||||
const props = defineProps<{
|
||||
extra: ITalkRecordExtraCode
|
||||
data: ITalkRecord
|
||||
maxWidth?: Boolean
|
||||
}>()
|
||||
|
||||
const { useMessage } = useUtil()
|
||||
const lineMumber = props.extra.code.trim().split('\n').length
|
||||
const full = ref(false)
|
||||
|
||||
const onClipboard = () => {
|
||||
clipboard(props.extra.code, () => {
|
||||
useMessage.success('复制成功')
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<section
|
||||
class="im-message-code el-container is-vertical"
|
||||
:class="{
|
||||
maxwidth: maxWidth,
|
||||
full: full
|
||||
}"
|
||||
>
|
||||
<header class="el-header tools">
|
||||
<p># {{ extra.lang }}</p>
|
||||
<p>
|
||||
<n-icon class="icon" :component="Stretching" @click="full = !full" />
|
||||
<n-icon class="icon" :component="Copy" @click="onClipboard" />
|
||||
</p>
|
||||
</header>
|
||||
<main class="el-main me-scrollbar me-scrollbar-thumb" :lineMumber="lineMumber">
|
||||
<n-code :language="extra.lang" :code="extra.code" show-line-numbers />
|
||||
<div class="el-footer mask pointer" v-show="lineMumber > 20" @click="full = !full">
|
||||
查看更多
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
</template>
|
||||
<style lang="less" scoped>
|
||||
.im-message-code {
|
||||
min-width: 300px;
|
||||
min-height: 100px;
|
||||
border-radius: 10px;
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 5px 8px;
|
||||
max-height: 500px;
|
||||
overflow-y: hidden;
|
||||
flex: unset;
|
||||
|
||||
.el-main {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
&.maxwidth {
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
&.full {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
background-color: var(--im-bg-color);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
box-sizing: border-box;
|
||||
max-width: unset;
|
||||
max-height: unset;
|
||||
overflow-y: unset;
|
||||
border-radius: unset;
|
||||
|
||||
.el-main {
|
||||
overflow-y: unset;
|
||||
}
|
||||
|
||||
.mask {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tools {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
padding: 0 8px;
|
||||
|
||||
.icon {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.mask {
|
||||
height: 80px;
|
||||
text-align: center;
|
||||
line-height: 10;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%);
|
||||
color: var(--im-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
html[theme-mode='dark'] {
|
||||
.im-message-code {
|
||||
background: var(--im-message-bg-color);
|
||||
|
||||
.mask {
|
||||
background: linear-gradient(to bottom, transparent 0%, var(--im-bg-color) 100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
118
src/components/talk/message/FileMessage.vue
Normal file
118
src/components/talk/message/FileMessage.vue
Normal file
@ -0,0 +1,118 @@
|
||||
<script lang="ts" setup>
|
||||
import { fileFormatSize } from '@/utils/strings'
|
||||
import { download, getFileNameSuffix } from '@/utils/functions'
|
||||
import { ITalkRecordExtraFile, ITalkRecord } from '@/types/chat'
|
||||
|
||||
defineProps<{
|
||||
extra: ITalkRecordExtraFile
|
||||
data: ITalkRecord
|
||||
maxWidth?: Boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="file-message">
|
||||
<div class="main">
|
||||
<div class="ext">{{ getFileNameSuffix(extra.name) }}</div>
|
||||
<div class="file-box">
|
||||
<p class="info">
|
||||
<span class="name">{{ extra.name }}</span>
|
||||
<span class="size">({{ fileFormatSize(extra.size) }})</span>
|
||||
</p>
|
||||
<p class="notice">文件已成功发送, 文件助手永久保存</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<a @click="download(data.msg_id)">下载</a>
|
||||
<a>在线预览</a>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.file-message {
|
||||
width: 250px;
|
||||
min-height: 85px;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--im-message-border-color);
|
||||
|
||||
.main {
|
||||
height: 45px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-top: 5px;
|
||||
|
||||
.ext {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
color: #ffffff;
|
||||
background: #49a4ff;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.file-box {
|
||||
flex: 1 1;
|
||||
height: 45px;
|
||||
margin-left: 10px;
|
||||
overflow: hidden;
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
height: 24px;
|
||||
font-size: 14px;
|
||||
|
||||
.name {
|
||||
flex: 1 auto;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.size {
|
||||
font-size: 12px;
|
||||
color: #cac6c6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.notice {
|
||||
height: 25px;
|
||||
line-height: 25px;
|
||||
font-size: 12px;
|
||||
color: #929191;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
height: 30px;
|
||||
line-height: 37px;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: 10px;
|
||||
|
||||
a {
|
||||
margin: 0 3px;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
color: var(--im-text-color);
|
||||
|
||||
&:hover {
|
||||
color: royalblue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
84
src/components/talk/message/ForwardMessage.vue
Normal file
84
src/components/talk/message/ForwardMessage.vue
Normal file
@ -0,0 +1,84 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import ForwardRecord from '../ForwardRecord.vue'
|
||||
import { ITalkRecordExtraForward, ITalkRecord } from '@/types/chat'
|
||||
|
||||
const props = defineProps<{
|
||||
extra: ITalkRecordExtraForward
|
||||
data: ITalkRecord
|
||||
maxWidth?: Boolean
|
||||
}>()
|
||||
|
||||
const isShowRecord = ref(false)
|
||||
|
||||
const title = computed(() => {
|
||||
return [...new Set(props.extra.records.map((v) => v.nickname))].join('、')
|
||||
})
|
||||
|
||||
const onClick = () => {
|
||||
isShowRecord.value = true
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<section class="im-message-forward pointer" @click="onClick">
|
||||
<div class="title">{{ title }} 的会话记录</div>
|
||||
<div class="list" v-for="(record, index) in extra.records" :key="index">
|
||||
<p>
|
||||
<span>{{ record.nickname }}: </span>
|
||||
<span>{{ record.text }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="tips">
|
||||
<span>转发:聊天会话记录 ({{ extra.msg_ids.length }}条)</span>
|
||||
</div>
|
||||
|
||||
<ForwardRecord v-if="isShowRecord" :msg-id="data.msg_id" @close="isShowRecord = false" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.im-message-forward {
|
||||
width: 250px;
|
||||
min-height: 95px;
|
||||
max-height: 150px;
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--im-message-border-color);
|
||||
user-select: none;
|
||||
|
||||
.title {
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
font-size: 15px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 400;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.list p {
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
font-size: 12px;
|
||||
color: #a8a8a8;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.tips {
|
||||
height: 32px;
|
||||
line-height: 35px;
|
||||
color: #8a8888;
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-size: 12px;
|
||||
margin-top: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
</style>
|
60
src/components/talk/message/GroupNoticeMessage.vue
Normal file
60
src/components/talk/message/GroupNoticeMessage.vue
Normal file
@ -0,0 +1,60 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { ITalkRecordExtraGroupNotice, ITalkRecord } from '@/types/chat'
|
||||
|
||||
defineProps<{
|
||||
extra: ITalkRecordExtraGroupNotice
|
||||
data: ITalkRecord
|
||||
maxWidth?: Boolean
|
||||
}>()
|
||||
|
||||
let show = ref(false)
|
||||
</script>
|
||||
<template>
|
||||
<section class="im-message-group-notice pointer" @click="show = !show">
|
||||
<div class="title">
|
||||
<n-tag :bordered="false" size="small" type="primary"> 群公告 </n-tag>
|
||||
《{{ extra.title }}》
|
||||
</div>
|
||||
<div class="content" :class="{ ellipsis: !show }">
|
||||
{{ extra.content }}
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.im-message-group-notice {
|
||||
max-width: 500px;
|
||||
min-height: 10px;
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--im-message-border-color);
|
||||
user-select: none;
|
||||
|
||||
.title {
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 400;
|
||||
margin-bottom: 5px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 13px;
|
||||
color: #a8a8a8;
|
||||
line-height: 24px;
|
||||
white-space: pre-wrap;
|
||||
|
||||
&.ellipsis {
|
||||
height: 24px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
60
src/components/talk/message/ImageMessage.vue
Normal file
60
src/components/talk/message/ImageMessage.vue
Normal file
@ -0,0 +1,60 @@
|
||||
<script lang="ts" setup>
|
||||
import { NImage } from 'naive-ui'
|
||||
import { getImageInfo } from '@/utils/functions'
|
||||
import { ITalkRecordExtraImage, ITalkRecord } from '@/types/chat'
|
||||
|
||||
defineProps<{
|
||||
extra: ITalkRecordExtraImage
|
||||
data: ITalkRecord
|
||||
maxWidth?: Boolean
|
||||
}>()
|
||||
|
||||
const img = (src: string, width = 200) => {
|
||||
const info = getImageInfo(src)
|
||||
|
||||
if (info.width == 0 || info.height == 0) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (info.width < width) {
|
||||
return {
|
||||
width: `${info.width}px`,
|
||||
height: `${info.height}px`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
width: width + 'px',
|
||||
height: `${info.height / (info.width / width)}px`
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<section
|
||||
class="im-message-image"
|
||||
:class="{ left: data.float === 'left' }"
|
||||
:style="img(extra.url, 350)"
|
||||
>
|
||||
<n-image :src="extra.url" />
|
||||
</section>
|
||||
</template>
|
||||
<style lang="less" scoped>
|
||||
.im-message-image {
|
||||
overflow: hidden;
|
||||
padding: 5px;
|
||||
border-radius: 5px;
|
||||
background: var(--im-message-left-bg-color);
|
||||
min-width: 30px;
|
||||
min-height: 30px;
|
||||
|
||||
&.left {
|
||||
background: var(--im-message-right-bg-color);
|
||||
}
|
||||
|
||||
:deep(.n-image img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 5px;
|
||||
}
|
||||
}
|
||||
</style>
|
67
src/components/talk/message/LoginMessage.vue
Normal file
67
src/components/talk/message/LoginMessage.vue
Normal file
@ -0,0 +1,67 @@
|
||||
<script lang="ts" setup>
|
||||
import { ITalkRecordExtraLogin, ITalkRecord } from '@/types/chat'
|
||||
|
||||
defineProps<{
|
||||
extra: ITalkRecordExtraLogin
|
||||
data: ITalkRecord
|
||||
maxWidth?: Boolean
|
||||
}>()
|
||||
|
||||
function getExploreName(userAgent = '') {
|
||||
if (userAgent.indexOf('Opera') > -1 || userAgent.indexOf('OPR') > -1) {
|
||||
return 'Opera'
|
||||
} else if (userAgent.indexOf('compatible') > -1 && userAgent.indexOf('MSIE') > -1) {
|
||||
return 'IE'
|
||||
} else if (userAgent.indexOf('Edge') > -1) {
|
||||
return 'Edge'
|
||||
} else if (userAgent.indexOf('Firefox') > -1) {
|
||||
return 'Firefox'
|
||||
} else if (userAgent.indexOf('Safari') > -1 && userAgent.indexOf('Chrome') == -1) {
|
||||
return 'Safari'
|
||||
} else if (userAgent.indexOf('Chrome') > -1 && userAgent.indexOf('Safari') > -1) {
|
||||
return 'Chrome'
|
||||
} else {
|
||||
return 'Unkonwn'
|
||||
}
|
||||
}
|
||||
|
||||
function getExploreOs(userAgent = '') {
|
||||
if (userAgent.indexOf('Mac OS') > -1) {
|
||||
return 'Mac OS'
|
||||
} else {
|
||||
return 'Windows'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<section class="im-message-login">
|
||||
<h4>登录操作通知</h4>
|
||||
<p>登录时间:{{ extra.datetime }} (CST)</p>
|
||||
<p>IP 地址:{{ extra.ip }}</p>
|
||||
<p>登录地点:{{ extra.address }}</p>
|
||||
<p>
|
||||
登录设备:{{ getExploreName(extra.agent) }} /
|
||||
{{ getExploreOs(extra.agent) }}
|
||||
</p>
|
||||
<p>异常原因:{{ extra.reason }}</p>
|
||||
</section>
|
||||
</template>
|
||||
<style lang="less" scoped>
|
||||
.im-message-login {
|
||||
width: 300px;
|
||||
min-height: 50px;
|
||||
background: var(--im-message-bg-color);
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
color: var(--im-text-color);
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
margin: 10px 0;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
105
src/components/talk/message/MixedMessage.vue
Normal file
105
src/components/talk/message/MixedMessage.vue
Normal file
@ -0,0 +1,105 @@
|
||||
<script lang="ts" setup>
|
||||
import { NImage } from 'naive-ui'
|
||||
import { textReplaceEmoji } from '@/utils/emojis'
|
||||
import { textReplaceLink } from '@/utils/strings'
|
||||
import { getImageInfo } from '@/utils/functions'
|
||||
import { ITalkRecordExtraMixed, ITalkRecord } from '@/types/chat'
|
||||
|
||||
const props = defineProps<{
|
||||
extra: ITalkRecordExtraMixed
|
||||
data: ITalkRecord
|
||||
maxWidth?: Boolean
|
||||
}>()
|
||||
|
||||
const float = props.data.float
|
||||
|
||||
const img = (src, width = 200) => {
|
||||
const info = getImageInfo(src)
|
||||
|
||||
if (info.width == 0 || info.height == 0) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (info.width < width) {
|
||||
return {
|
||||
width: `${info.width}px`,
|
||||
height: `${info.height}px`
|
||||
}
|
||||
}
|
||||
|
||||
let h = info.height / (info.width / width)
|
||||
|
||||
return {
|
||||
width: width + 'px',
|
||||
height: h + 'px'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="im-message-mixed"
|
||||
:class="{
|
||||
left: float == 'left',
|
||||
right: float == 'right',
|
||||
maxwidth: maxWidth
|
||||
}"
|
||||
>
|
||||
<pre>
|
||||
<template v-for="(item) in extra.items" :key="item.id">
|
||||
|
||||
<template v-if="item.type === 1">
|
||||
<span v-html="textReplaceEmoji(textReplaceLink(item.content))" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="item.type === 3">
|
||||
<div
|
||||
:style="img(item.content, 300)"
|
||||
style="display: flex; margin: 5px 0;border-radius: 8px;overflow: hidden;;"
|
||||
>
|
||||
<n-image :src="item.content"></n-image>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.im-message-mixed {
|
||||
min-width: 30px;
|
||||
min-height: 30px;
|
||||
padding: 3px;
|
||||
color: var(--im-message-left-text-color);
|
||||
background: var(--im-message-left-bg-color);
|
||||
border-radius: 0px 10px 10px 10px;
|
||||
|
||||
&.right {
|
||||
background-color: var(--im-message-right-bg-color);
|
||||
color: var(--im-message-right-text-color);
|
||||
border-radius: 10px 0px 10px 10px;
|
||||
}
|
||||
|
||||
&.maxwidth {
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
pre {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
white-space: pre-wrap;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
word-wrap: break-word;
|
||||
font-size: 14px;
|
||||
padding: 3px 5px;
|
||||
font-family: 'PingFang SC', 'Microsoft YaHei', 'Alibaba PuHuiTi 2.0 45';
|
||||
line-height: 25px;
|
||||
|
||||
:deep(a) {
|
||||
color: #2196f3;
|
||||
text-decoration: revert;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
71
src/components/talk/message/RevokeMessage.vue
Normal file
71
src/components/talk/message/RevokeMessage.vue
Normal file
@ -0,0 +1,71 @@
|
||||
<script setup>
|
||||
import { formatTime } from '@/utils/datetime'
|
||||
|
||||
defineProps({
|
||||
login_uid: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
user_id: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
talk_type: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
nickname: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
datetime: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div class="im-message-revoke">
|
||||
<div class="content">
|
||||
<span v-if="login_uid == user_id"> 你撤回了一条消息 | {{ formatTime(datetime) }} </span>
|
||||
<span v-else-if="talk_type == 1"> 对方撤回了一条消息 | {{ formatTime(datetime) }} </span>
|
||||
<span v-else>
|
||||
"{{ nickname }}" 撤回了一条消息 |
|
||||
{{ formatTime(datetime) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="less" scoped>
|
||||
.im-message-revoke {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
.content {
|
||||
margin: 10px auto;
|
||||
background-color: #f5f5f5;
|
||||
font-size: 11px;
|
||||
line-height: 30px;
|
||||
padding: 0 8px;
|
||||
word-break: break-all;
|
||||
word-wrap: break-word;
|
||||
color: #979191;
|
||||
user-select: none;
|
||||
font-weight: 300;
|
||||
display: inline-block;
|
||||
border-radius: 3px;
|
||||
|
||||
span {
|
||||
margin: 0 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
html[theme-mode='dark'] {
|
||||
.im-message-revoke {
|
||||
.content {
|
||||
background: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
84
src/components/talk/message/TextMessage.vue
Normal file
84
src/components/talk/message/TextMessage.vue
Normal file
@ -0,0 +1,84 @@
|
||||
<script lang="ts" setup>
|
||||
import { textReplaceEmoji } from '@/utils/emojis'
|
||||
import { textReplaceLink, textReplaceMention } from '@/utils/strings'
|
||||
import { ITalkRecordExtraText, ITalkRecord } from '@/types/chat'
|
||||
|
||||
const props = defineProps<{
|
||||
extra: ITalkRecordExtraText
|
||||
data: ITalkRecord
|
||||
maxWidth?: boolean
|
||||
source?: 'panel' | 'forward' | 'history'
|
||||
}>()
|
||||
|
||||
const float = props.data.float
|
||||
|
||||
let textContent = props.extra?.content || ''
|
||||
|
||||
textContent = textReplaceLink(textContent)
|
||||
|
||||
if (props.data.talk_type == 2) {
|
||||
textContent = textReplaceMention(textContent, '#1890ff')
|
||||
}
|
||||
|
||||
textContent = textReplaceEmoji(textContent)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="im-message-text"
|
||||
:class="{
|
||||
left: float == 'left',
|
||||
right: float == 'right',
|
||||
maxwidth: maxWidth,
|
||||
'radius-reset': source != 'panel'
|
||||
}"
|
||||
>
|
||||
<pre v-html="textContent" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.im-message-text {
|
||||
min-width: 30px;
|
||||
min-height: 30px;
|
||||
padding: 3px;
|
||||
color: var(--im-message-left-text-color);
|
||||
background: var(--im-message-left-bg-color);
|
||||
border-radius: 0px 10px 10px 10px;
|
||||
|
||||
&.right {
|
||||
background-color: var(--im-message-right-bg-color);
|
||||
color: var(--im-message-right-text-color);
|
||||
border-radius: 10px 0px 10px 10px;
|
||||
}
|
||||
|
||||
&.maxwidth {
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
&.radius-reset {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
word-wrap: break-word;
|
||||
font-size: 14px;
|
||||
padding: 3px 5px;
|
||||
font-family: 'PingFang SC', 'Microsoft YaHei', 'Alibaba PuHuiTi 2.0 45';
|
||||
line-height: 25px;
|
||||
|
||||
:deep(.emoji) {
|
||||
vertical-align: text-bottom;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
:deep(a) {
|
||||
color: #2196f3;
|
||||
text-decoration: revert;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
23
src/components/talk/message/UnknownMessage.vue
Normal file
23
src/components/talk/message/UnknownMessage.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
extra: Object,
|
||||
data: Object
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="im-message-unknown">[{{ data.msg_type }}] 未知消息类型</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.im-message-unknown {
|
||||
height: 35px;
|
||||
line-height: 35px;
|
||||
border-radius: 20px;
|
||||
color: #979191;
|
||||
background: #eff0f1;
|
||||
width: 150px;
|
||||
text-align: center;
|
||||
font-weight: 300;
|
||||
}
|
||||
</style>
|
137
src/components/talk/message/VideoMessage.vue
Normal file
137
src/components/talk/message/VideoMessage.vue
Normal file
@ -0,0 +1,137 @@
|
||||
<script lang="ts" setup>
|
||||
import 'xgplayer/dist/index.min.css'
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { NImage, NModal, NCard } from 'naive-ui'
|
||||
import { Play, Close } from '@icon-park/vue-next'
|
||||
import { getImageInfo } from '@/utils/functions'
|
||||
import Player from 'xgplayer'
|
||||
import { ITalkRecordExtraVideo, ITalkRecord } from '@/types/chat'
|
||||
|
||||
const props = defineProps<{
|
||||
extra: ITalkRecordExtraVideo
|
||||
data: ITalkRecord
|
||||
maxWidth?: Boolean
|
||||
}>()
|
||||
|
||||
const img = (src: string, width = 200) => {
|
||||
const info: any = getImageInfo(src)
|
||||
|
||||
if (info.width == 0 || info.height == 0) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (info.height > 300) {
|
||||
return {
|
||||
height: '300px'
|
||||
}
|
||||
}
|
||||
|
||||
if (info.width < width) {
|
||||
return {
|
||||
width: `${info.width}px`,
|
||||
height: `${info.height}px`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
width: width + 'px',
|
||||
height: info.height / (info.width / width) + 'px'
|
||||
}
|
||||
}
|
||||
|
||||
const open = ref(false)
|
||||
|
||||
async function onPlay() {
|
||||
open.value = true
|
||||
|
||||
await nextTick()
|
||||
|
||||
new Player({
|
||||
id: 'im-xgplayer',
|
||||
url: props.extra.url,
|
||||
fluid: true,
|
||||
autoplay: true,
|
||||
lang: 'zh-cn'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<section
|
||||
class="im-message-video"
|
||||
:class="{ left: data.float === 'left' }"
|
||||
:style="img(extra.cover, 350)"
|
||||
@click="onPlay"
|
||||
>
|
||||
<n-image :src="extra.cover" preview-disabled />
|
||||
|
||||
<div class="btn-video">
|
||||
<n-icon :component="Play" size="36" />
|
||||
</div>
|
||||
|
||||
<n-modal v-model:show="open">
|
||||
<n-card
|
||||
style="width: 800px; min-height: 300px; background-color: #ffffff; position: relative"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div id="im-xgplayer"></div>
|
||||
<div class="im-xgplayer-close" @click="open = false">
|
||||
<n-icon :component="Close" size="18" />
|
||||
</div>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
</section>
|
||||
</template>
|
||||
<style lang="less" scoped>
|
||||
.im-message-video {
|
||||
overflow: hidden;
|
||||
padding: 5px;
|
||||
border-radius: 5px;
|
||||
background: var(--im-message-left-bg-color);
|
||||
min-width: 30px;
|
||||
min-height: 30px;
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
|
||||
&.left {
|
||||
background: var(--im-message-right-bg-color);
|
||||
}
|
||||
|
||||
:deep(.n-image img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.btn-video {
|
||||
width: 30px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: calc(50% - 15px);
|
||||
top: calc(50% - 10px);
|
||||
cursor: pointer;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.btn-video {
|
||||
color: #03a9f4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.im-xgplayer-close {
|
||||
position: absolute;
|
||||
height: 35px;
|
||||
width: 35px;
|
||||
background-color: #f5f5f5;
|
||||
right: -45px;
|
||||
top: -45px;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
color: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
281
src/components/talk/message/VoteMessage.vue
Normal file
281
src/components/talk/message/VoteMessage.vue
Normal file
@ -0,0 +1,281 @@
|
||||
<script setup>
|
||||
import { reactive, computed, onMounted, ref } from 'vue'
|
||||
import { NCheckbox, NProgress } from 'naive-ui'
|
||||
import { ServeConfirmVoteHandle } from '@/api/chat'
|
||||
import { useUserStore } from '@/store'
|
||||
|
||||
const props = defineProps({
|
||||
extra: Object,
|
||||
data: Object
|
||||
})
|
||||
|
||||
const extra = ref(props.extra)
|
||||
const userStore = useUserStore()
|
||||
const mode = extra.value.detail.answer_mode
|
||||
const state = reactive({ options: [] })
|
||||
|
||||
// 是否可提交
|
||||
const isCanSubmit = computed(() => {
|
||||
return state.options.some((item) => item.is_checked)
|
||||
})
|
||||
|
||||
// 是否已投票
|
||||
const isVoted = computed(() => {
|
||||
return extra.value.vote_users.some((item) => item == userStore.uid)
|
||||
})
|
||||
|
||||
/**
|
||||
* 设置投票选项
|
||||
*/
|
||||
function setOptions(options) {
|
||||
for (const option of options) {
|
||||
state.options.push({
|
||||
key: option.key,
|
||||
value: option.value,
|
||||
is_checked: false,
|
||||
num: 0,
|
||||
progress: 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新统计信息
|
||||
*
|
||||
* @param {*} data
|
||||
*/
|
||||
function updateStatistics(data) {
|
||||
let count = data.count
|
||||
|
||||
state.options.forEach((option) => {
|
||||
option.num = data.options[option.key]
|
||||
|
||||
if (count > 0) {
|
||||
option.progress = (data.options[option.key] / count) * 100
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择投票
|
||||
*
|
||||
* @param {*} data
|
||||
* @param {*} option
|
||||
*/
|
||||
function change(data, option) {
|
||||
if (mode == 0) {
|
||||
state.options.forEach((option) => (option.is_checked = false))
|
||||
}
|
||||
|
||||
option.is_checked = data
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单提交
|
||||
*/
|
||||
const onSubmit = () => {
|
||||
if (!isCanSubmit.value) return
|
||||
|
||||
let items = []
|
||||
|
||||
state.options.forEach((item) => {
|
||||
item.is_checked && items.push(item.key)
|
||||
})
|
||||
|
||||
ServeConfirmVoteHandle({
|
||||
msg_id: props.data.msg_id,
|
||||
options: items.join(',')
|
||||
}).then((res) => {
|
||||
if (res.code == 200) {
|
||||
updateStatistics(res.data)
|
||||
extra.value.vote_users.push(userStore.uid)
|
||||
extra.value.detail.answered_num++
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setOptions(extra.value.detail.answer_option)
|
||||
updateStatistics(extra.value.statistics)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="im-message-vote">
|
||||
<div class="vote-from">
|
||||
<div class="vheader">
|
||||
<p style="font-weight: bold">
|
||||
{{ mode == 1 ? '[多选投票]' : '[单选投票]' }}
|
||||
</p>
|
||||
<p>{{ extra.detail.title }}</p>
|
||||
</div>
|
||||
|
||||
<template v-if="isVoted">
|
||||
<div class="vbody">
|
||||
<div class="vote-view" v-for="option in state.options" :key="option.key">
|
||||
<p class="vote-option">{{ option.key }}、 {{ option.value }}</p>
|
||||
<p class="vote-census">{{ option.num }} 票 {{ option.progress }}%</p>
|
||||
<p class="vote-progress">
|
||||
<n-progress
|
||||
type="line"
|
||||
:height="5"
|
||||
:show-indicator="false"
|
||||
:percentage="parseInt(option.progress)"
|
||||
color="#1890ff"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vfooter vote-view">
|
||||
<p>应参与人数:{{ extra.detail.answer_num }} 人</p>
|
||||
<p>实际参与人数:{{ extra.detail.answered_num }} 人</p>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="vbody">
|
||||
<div
|
||||
class="option"
|
||||
:class="{ radio: mode == 0 }"
|
||||
v-for="option in state.options"
|
||||
:key="option.key"
|
||||
>
|
||||
<p class="checkbox">
|
||||
<n-checkbox
|
||||
v-model:checked="option.is_checked"
|
||||
@update:checked="change(option.is_checked, option)"
|
||||
/>
|
||||
</p>
|
||||
<p class="text" @click="change(!option.is_checked, option)">
|
||||
{{ option.key }}、{{ option.value }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vfooter">
|
||||
<n-button plain round @click="onSubmit">
|
||||
{{ isCanSubmit ? '立即投票' : '请选择进行投票' }}
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.im-message-vote {
|
||||
width: 300px;
|
||||
min-height: 150px;
|
||||
border: 1px solid var(--border-color);
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
|
||||
.vote-from {
|
||||
width: 100%;
|
||||
|
||||
.vheader {
|
||||
min-height: 50px;
|
||||
background: #4e83fd;
|
||||
padding: 15px;
|
||||
position: relative;
|
||||
|
||||
p {
|
||||
margin: 3px 0;
|
||||
&:first-child {
|
||||
color: rgb(245, 237, 237);
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '投票';
|
||||
position: absolute;
|
||||
font-size: 60px;
|
||||
color: white;
|
||||
opacity: 0.1;
|
||||
top: -5px;
|
||||
right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.vbody {
|
||||
min-height: 80px;
|
||||
width: 100%;
|
||||
padding: 5px 15px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.option {
|
||||
margin: 14px 0px;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.text {
|
||||
margin-left: 10px;
|
||||
cursor: pointer;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
&.radio {
|
||||
:deep(.n-checkbox-box) {
|
||||
border-radius: 50%;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.vfooter {
|
||||
height: 55px;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
|
||||
.n-button {
|
||||
width: 90%;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&.vote-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
padding-left: 15px;
|
||||
|
||||
p {
|
||||
border-left: 2px solid #2196f3;
|
||||
padding-left: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.vote-view {
|
||||
width: 100%;
|
||||
min-height: 30px;
|
||||
margin: 15px 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
> p {
|
||||
margin: 6px 0px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.vote-option {
|
||||
min-height: 20px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.vote-census {
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
11
src/components/talk/message/index.js
Normal file
11
src/components/talk/message/index.js
Normal file
@ -0,0 +1,11 @@
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
export function setComponents(app) {
|
||||
// 动态导出当前目录下的组件
|
||||
const modules = import.meta.glob(['./*.vue', './system/*.vue'])
|
||||
|
||||
for (const [key, value] of Object.entries(modules)) {
|
||||
const name = key.slice(key.lastIndexOf('/') + 1, key.lastIndexOf('.'))
|
||||
app.component(name, defineAsyncComponent(value))
|
||||
}
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import './sys-message.less'
|
||||
import { useInject } from '@/hooks'
|
||||
|
||||
const { showUserInfoModal } = useInject()
|
||||
|
||||
defineProps({
|
||||
extra: Object,
|
||||
data: Object
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="im-message-sys-text">
|
||||
<div class="sys-text">
|
||||
<a @click="showUserInfoModal(extra.owner_id)">
|
||||
{{ extra.owner_name }}
|
||||
</a>
|
||||
|
||||
<span>取消了全员禁言</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
28
src/components/talk/message/system/SysGroupCreateMessage.vue
Normal file
28
src/components/talk/message/system/SysGroupCreateMessage.vue
Normal file
@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
import './sys-message.less'
|
||||
import { useInject } from '@/hooks'
|
||||
|
||||
defineProps({
|
||||
extra: Object,
|
||||
data: Object
|
||||
})
|
||||
|
||||
const { showUserInfoModal } = useInject()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="im-message-sys-text">
|
||||
<div class="sys-text">
|
||||
<a @click="showUserInfoModal(extra.owner_id)">
|
||||
{{ extra.owner_name }}
|
||||
</a>
|
||||
|
||||
<span>创建了群聊,并邀请了</span>
|
||||
|
||||
<template v-for="(user, index) in extra.members" :key="index">
|
||||
<a @click="showUserInfoModal(user.user_id)">{{ user.nickname }}</a>
|
||||
<em v-show="index < extra.members.length - 1">、</em>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
30
src/components/talk/message/system/SysGroupJoinMessage.vue
Normal file
30
src/components/talk/message/system/SysGroupJoinMessage.vue
Normal file
@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import './sys-message.less'
|
||||
import { useInject } from '@/hooks'
|
||||
|
||||
defineProps({
|
||||
extra: Object,
|
||||
data: Object
|
||||
})
|
||||
|
||||
const { showUserInfoModal } = useInject()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="im-message-sys-text">
|
||||
<div class="sys-text">
|
||||
<a @click="showUserInfoModal(extra.owner_id)">
|
||||
{{ extra.owner_name }}
|
||||
</a>
|
||||
|
||||
<span>邀请了</span>
|
||||
|
||||
<template v-for="(user, index) in extra.members" :key="index">
|
||||
<a @click="showUserInfoModal(user.user_id)">{{ user.nickname }}</a>
|
||||
<em v-show="index < extra.members.length - 1">、</em>
|
||||
</template>
|
||||
|
||||
<span>加入群聊</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import './sys-message.less'
|
||||
import { useInject } from '@/hooks'
|
||||
|
||||
defineProps({
|
||||
extra: Object,
|
||||
data: Object
|
||||
})
|
||||
|
||||
const { showUserInfoModal } = useInject()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="im-message-sys-text">
|
||||
<div class="sys-text">
|
||||
<a @click="showUserInfoModal(extra.owner_id)">
|
||||
{{ extra.owner_name }}
|
||||
</a>
|
||||
|
||||
<span>解除了</span>
|
||||
|
||||
<template v-for="(user, index) in extra.members" :key="index">
|
||||
<a @click="showUserInfoModal(user.user_id)">{{ user.nickname }}</a>
|
||||
<em v-show="index < extra.members.length - 1">、</em>
|
||||
</template>
|
||||
|
||||
<span>禁言</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import './sys-message.less'
|
||||
import { useInject } from '@/hooks'
|
||||
|
||||
defineProps({
|
||||
extra: Object,
|
||||
data: Object
|
||||
})
|
||||
|
||||
const { showUserInfoModal } = useInject()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="im-message-sys-text">
|
||||
<div class="sys-text">
|
||||
<a @click="showUserInfoModal(extra.owner_id)">
|
||||
{{ extra.owner_name }}
|
||||
</a>
|
||||
|
||||
<span>将</span>
|
||||
|
||||
<template v-for="(user, index) in extra.members" :key="index">
|
||||
<a @click="showUserInfoModal(user.user_id)">{{ user.nickname }}</a>
|
||||
<em v-show="index < extra.members.length - 1">、</em>
|
||||
</template>
|
||||
|
||||
<span>踢出群聊</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import './sys-message.less'
|
||||
import { useInject } from '@/hooks'
|
||||
|
||||
defineProps({
|
||||
extra: Object,
|
||||
data: Object
|
||||
})
|
||||
|
||||
const { showUserInfoModal } = useInject()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="im-message-sys-text">
|
||||
<div class="sys-text">
|
||||
<a @click="showUserInfoModal(extra.owner_id)">
|
||||
{{ extra.owner_name }}
|
||||
</a>
|
||||
|
||||
<span>设置了</span>
|
||||
|
||||
<template v-for="(user, index) in extra.members" :key="index">
|
||||
<a @click="showUserInfoModal(user.user_id)">{{ user.nickname }}</a>
|
||||
<em v-show="index < extra.members.length - 1">、</em>
|
||||
</template>
|
||||
|
||||
<span>禁言</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import './sys-message.less'
|
||||
import { useInject } from '@/hooks'
|
||||
|
||||
defineProps({
|
||||
extra: Object,
|
||||
data: Object
|
||||
})
|
||||
|
||||
const { showUserInfoModal } = useInject()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="im-message-sys-text">
|
||||
<div class="sys-text">
|
||||
<a @click="showUserInfoModal(extra.owner_id)">
|
||||
{{ extra.owner_name }}
|
||||
</a>
|
||||
|
||||
<span>退出了群聊</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
23
src/components/talk/message/system/SysGroupMutedMessage.vue
Normal file
23
src/components/talk/message/system/SysGroupMutedMessage.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import './sys-message.less'
|
||||
import { useInject } from '@/hooks'
|
||||
|
||||
defineProps({
|
||||
extra: Object,
|
||||
data: Object
|
||||
})
|
||||
|
||||
const { showUserInfoModal } = useInject()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="im-message-sys-text">
|
||||
<div class="sys-text">
|
||||
<a @click="showUserInfoModal(extra.owner_id)">
|
||||
{{ extra.owner_name }}
|
||||
</a>
|
||||
|
||||
<span>设置了全员禁言</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import './sys-message.less'
|
||||
import { useInject } from '@/hooks'
|
||||
|
||||
defineProps({
|
||||
extra: Object,
|
||||
data: Object
|
||||
})
|
||||
|
||||
const { showUserInfoModal } = useInject()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="im-message-sys-text">
|
||||
<div class="sys-text">
|
||||
<a @click="showUserInfoModal(extra.old_owner_id)">{{ extra.old_owner_name }}</a>
|
||||
<span>将群主转让给</span>
|
||||
<a @click="showUserInfoModal(extra.new_owner_id)">{{ extra.new_owner_name }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
45
src/components/talk/message/system/SysTextMessage.vue
Normal file
45
src/components/talk/message/system/SysTextMessage.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
extra: Object,
|
||||
data: Object
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="im-message-system-text">
|
||||
<div class="content">
|
||||
{{ extra.content }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.im-message-system-text {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
.content {
|
||||
margin: 10px auto;
|
||||
background-color: #f5f5f5;
|
||||
font-size: 11px;
|
||||
line-height: 30px;
|
||||
padding: 0 8px;
|
||||
word-break: break-all;
|
||||
word-wrap: break-word;
|
||||
color: #979191;
|
||||
user-select: none;
|
||||
font-weight: 300;
|
||||
display: inline-block;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
html[theme-mode='dark'] {
|
||||
.im-message-system-text {
|
||||
.content {
|
||||
background: unset;
|
||||
color: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
43
src/components/talk/message/system/sys-message.less
Normal file
43
src/components/talk/message/system/sys-message.less
Normal file
@ -0,0 +1,43 @@
|
||||
.im-message-sys-text {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
.sys-text {
|
||||
margin: 10px auto;
|
||||
background-color: #f5f5f5;
|
||||
font-size: 11px;
|
||||
line-height: 30px;
|
||||
padding: 0 8px;
|
||||
word-wrap: break-word;
|
||||
color: #979191;
|
||||
user-select: none;
|
||||
font-weight: 300;
|
||||
display: inline-block;
|
||||
border-radius: 3px;
|
||||
max-width: 80%;
|
||||
text-align: center;
|
||||
|
||||
span {
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #939596;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
html[theme-mode='dark'] {
|
||||
.im-message-sys-text {
|
||||
.sys-text {
|
||||
background: unset;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
// import { h } from 'vue'
|
||||
// import { NAvatar } from 'naive-ui'
|
||||
import { useTalkStore,useUserStore } from '@/store'
|
||||
import { useTalkStore,useUserStore,useDialogueStore } from '@/store'
|
||||
// import { notifyIcon } from '@/constant/default'
|
||||
import WsSocket from './plugins/ws-socket'
|
||||
import EventTalk from './event/talk'
|
||||
@ -86,7 +86,7 @@ class Connect {
|
||||
this.onPing()
|
||||
this.onPong()
|
||||
this.onImMessage()
|
||||
// this.onImMessageRead()
|
||||
this.onImMessageRead()
|
||||
// this.onImContactStatus()
|
||||
this.onImMessageRevoke()
|
||||
// this.onImMessageKeyboard()
|
||||
@ -104,21 +104,21 @@ class Connect {
|
||||
this.conn.on('im.message', (data) => new EventTalk(data))
|
||||
}
|
||||
|
||||
// onImMessageRead() {
|
||||
// this.conn.on('im.message.read', (data) => {
|
||||
// const dialogueStore = useDialogueStore()
|
||||
onImMessageRead() {
|
||||
this.conn.on('im.message.read', (data) => {
|
||||
const dialogueStore = useDialogueStore()
|
||||
|
||||
// if (dialogueStore.index_name !== `1_${data.sender_id}`) {
|
||||
// return
|
||||
// }
|
||||
if (dialogueStore.index_name !== `1_${data.sender_id}`) {
|
||||
return
|
||||
}
|
||||
|
||||
// const { msg_ids = [] } = data
|
||||
const { msg_ids = [] } = data
|
||||
|
||||
// for (const msgid of msg_ids) {
|
||||
// dialogueStore.updateDialogueRecord({ msg_id: msgid, is_read: 1 })
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
for (const msgid of msg_ids) {
|
||||
dialogueStore.updateDialogueRecord({ msg_id: msgid, is_read: 1 })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onImContactStatus() {
|
||||
// 好友在线状态事件
|
||||
|
21
src/constant/default.ts
Normal file
21
src/constant/default.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import avatar from '@/static/image/chatList/notify.png'
|
||||
import notify from '@/static/image/chatList/notify.png'
|
||||
|
||||
export const GenderOptions = [
|
||||
{
|
||||
label: '未知',
|
||||
value: '0'
|
||||
},
|
||||
{
|
||||
label: '男',
|
||||
value: '1'
|
||||
},
|
||||
{
|
||||
label: '女',
|
||||
value: '2'
|
||||
}
|
||||
]
|
||||
|
||||
export const defAvatar = avatar
|
||||
|
||||
export const notifyIcon = notify
|
104
src/directive/dropsize.js
Normal file
104
src/directive/dropsize.js
Normal file
@ -0,0 +1,104 @@
|
||||
import { storage } from '@/utils/storage'
|
||||
|
||||
function getCacheKey(key, direction) {
|
||||
if (!key.length) return ''
|
||||
|
||||
return `dropsize_${direction}_${key}`
|
||||
}
|
||||
|
||||
export default {
|
||||
mounted: function (el, binding) {
|
||||
let { min, max, direction = 'right', key = '' } = binding.value
|
||||
|
||||
el.style.position = 'relative'
|
||||
el.touch = { status: false, pageX: 0, pageY: 0, width: 0, height: 0 }
|
||||
|
||||
const cacheKey = getCacheKey(key, direction)
|
||||
const cursor = ['left', 'right'].includes(direction) ? 'col-resize' : 'row-resize'
|
||||
|
||||
const linedom = document.createElement('div')
|
||||
linedom.className = `dropsize-line dropsize-line-${direction}`
|
||||
|
||||
el.linedomMouseup = function () {
|
||||
if (!el.touch.status) return
|
||||
|
||||
el.touch.status = false
|
||||
|
||||
linedom.classList.remove('dropsize-resizing')
|
||||
|
||||
document.querySelector('body').classList.remove(`dropsize-${cursor}`)
|
||||
}
|
||||
|
||||
el.linedomMousemove = function (e) {
|
||||
if (!el.touch.status) return
|
||||
|
||||
let width,
|
||||
height = 0
|
||||
switch (direction) {
|
||||
case 'left':
|
||||
case 'right':
|
||||
if (direction == 'left') {
|
||||
width = el.touch.width + el.touch.pageX - e.pageX
|
||||
} else {
|
||||
width = el.touch.width + e.pageX - el.touch.pageX
|
||||
}
|
||||
|
||||
if (width < min) width = min
|
||||
if (width > max) width = max
|
||||
|
||||
el.style.width = `${width}px`
|
||||
|
||||
cacheKey && storage.set(cacheKey, width)
|
||||
break
|
||||
case 'top':
|
||||
case 'bottom':
|
||||
if (direction == 'top') {
|
||||
height = el.touch.height + el.touch.pageY - e.pageY
|
||||
} else {
|
||||
height = el.touch.height + e.pageY - el.touch.pageY
|
||||
}
|
||||
|
||||
if (height < min) height = min
|
||||
if (height > max) height = max
|
||||
|
||||
el.style.height = `${height}px`
|
||||
|
||||
cacheKey && storage.set(cacheKey, height)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
linedom.addEventListener('mousedown', function (e) {
|
||||
el.touch = {
|
||||
status: true,
|
||||
pageX: e.pageX,
|
||||
pageY: e.pageY,
|
||||
width: el.offsetWidth,
|
||||
height: el.offsetHeight
|
||||
}
|
||||
|
||||
this.classList.add('dropsize-resizing')
|
||||
|
||||
document.querySelector('body').classList.add(`dropsize-${cursor}`)
|
||||
|
||||
document.addEventListener('mouseup', el.linedomMouseup)
|
||||
document.addEventListener('mousemove', el.linedomMousemove)
|
||||
})
|
||||
|
||||
if (cacheKey) {
|
||||
const value = storage.get(cacheKey)
|
||||
|
||||
if (direction == 'left' || direction == 'right') {
|
||||
el.style.width = `${value}px`
|
||||
} else {
|
||||
el.style.height = `${value}px`
|
||||
}
|
||||
}
|
||||
|
||||
el.appendChild(linedom)
|
||||
},
|
||||
unmounted: function (el) {
|
||||
document.removeEventListener('mousemove', el.linedomMouseup)
|
||||
document.removeEventListener('mouseup', el.linedomMousemove)
|
||||
}
|
||||
}
|
7
src/directive/focus.js
Normal file
7
src/directive/focus.js
Normal file
@ -0,0 +1,7 @@
|
||||
export default {
|
||||
mounted(el) {
|
||||
setTimeout(() => {
|
||||
el.focus()
|
||||
}, 0)
|
||||
}
|
||||
}
|
15
src/directive/index.ts
Normal file
15
src/directive/index.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import dropsize from './dropsize'
|
||||
import focus from './focus'
|
||||
import loading from './loading'
|
||||
|
||||
const directives = {
|
||||
dropsize,
|
||||
focus,
|
||||
loading
|
||||
}
|
||||
|
||||
export function setupDirective(app: any) {
|
||||
for (const key in directives) {
|
||||
app.directive(key, directives[key])
|
||||
}
|
||||
}
|
177
src/directive/inner/loading.vue
Normal file
177
src/directive/inner/loading.vue
Normal file
@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div class="loading-content">
|
||||
<div class="ant-spin ant-spin-spinning">
|
||||
<span class="ant-spin-dot ant-spin-dot-spin">
|
||||
<i class="ant-spin-dot-item"></i>
|
||||
<i class="ant-spin-dot-item"></i>
|
||||
<i class="ant-spin-dot-item"></i>
|
||||
<i class="ant-spin-dot-item"></i>
|
||||
</span>
|
||||
<div class="ant-spin-text" style="color: #9b9b9b; margin-top: 8px">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.loading-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
background: var(--im-bg-color);
|
||||
z-index: 1000;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
|
||||
p {
|
||||
margin-top: 10px;
|
||||
color: rgb(194 194 194);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-spin {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: #000000d9;
|
||||
font-size: 14px;
|
||||
font-variant: tabular-nums;
|
||||
line-height: 1.5715;
|
||||
list-style: none;
|
||||
font-feature-settings: 'tnum';
|
||||
position: absolute;
|
||||
display: none;
|
||||
color: #1890ff;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
opacity: 0;
|
||||
transition: transform 0.3s cubic-bezier(0.78, 0.14, 0.15, 0.86);
|
||||
}
|
||||
.ant-spin-spinning {
|
||||
position: static;
|
||||
display: inline-block;
|
||||
opacity: 1;
|
||||
}
|
||||
.ant-spin-container {
|
||||
position: relative;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.ant-spin-container:after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
display: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
opacity: 0;
|
||||
transition: all 0.3s;
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
}
|
||||
.ant-spin-blur {
|
||||
clear: both;
|
||||
opacity: 0.5;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ant-spin-blur:after {
|
||||
opacity: 0.4;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.ant-spin-tip {
|
||||
color: #00000073;
|
||||
}
|
||||
.ant-spin-dot {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
font-size: 20px;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
.ant-spin-dot-item {
|
||||
position: absolute;
|
||||
display: block;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
background-color: #1890ff;
|
||||
border-radius: 100%;
|
||||
transform: scale(0.75);
|
||||
transform-origin: 50% 50%;
|
||||
opacity: 0.3;
|
||||
animation: antSpinMove 1s infinite linear alternate;
|
||||
}
|
||||
.ant-spin-dot-item:nth-child(1) {
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
.ant-spin-dot-item:nth-child(2) {
|
||||
top: 0;
|
||||
right: 0;
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
.ant-spin-dot-item:nth-child(3) {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
animation-delay: 0.8s;
|
||||
}
|
||||
.ant-spin-dot-item:nth-child(4) {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
animation-delay: 1.2s;
|
||||
}
|
||||
.ant-spin-dot-spin {
|
||||
transform: rotate(45deg);
|
||||
animation: antRotate 1.2s infinite linear;
|
||||
}
|
||||
.ant-spin-sm .ant-spin-dot {
|
||||
font-size: 14px;
|
||||
}
|
||||
.ant-spin-sm .ant-spin-dot i {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.ant-spin-lg .ant-spin-dot {
|
||||
font-size: 32px;
|
||||
}
|
||||
.ant-spin-lg .ant-spin-dot i {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.ant-spin.ant-spin-show-text .ant-spin-text {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
|
||||
.ant-spin-blur {
|
||||
background: #fff;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
.ant-spin-rtl {
|
||||
direction: rtl;
|
||||
}
|
||||
.ant-spin-rtl .ant-spin-dot-spin {
|
||||
transform: rotate(-45deg);
|
||||
animation-name: antRotateRtl;
|
||||
}
|
||||
@keyframes antSpinMove {
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes antRotate {
|
||||
to {
|
||||
transform: rotate(405deg);
|
||||
}
|
||||
}
|
||||
</style>
|
55
src/directive/loading.js
Normal file
55
src/directive/loading.js
Normal file
@ -0,0 +1,55 @@
|
||||
import { createApp } from 'vue'
|
||||
import Loading from './inner/loading.vue'
|
||||
|
||||
export default {
|
||||
mounted(el, binding) {
|
||||
const app = createApp(Loading)
|
||||
const instance = app.mount(document.createElement('div'))
|
||||
el.instance = instance
|
||||
|
||||
if (binding.value) {
|
||||
if (el.style.position == '') {
|
||||
el.dataset['position'] = true
|
||||
}
|
||||
|
||||
if (el.style.overflow == '') {
|
||||
el.dataset['overflow'] = true
|
||||
}
|
||||
|
||||
appendEl(el)
|
||||
}
|
||||
},
|
||||
updated(el, binding) {
|
||||
if (binding.value !== binding.oldValue) {
|
||||
binding.value ? appendEl(el) : removeEl(el)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 插入元素
|
||||
const appendEl = (el) => {
|
||||
// 给父元素加个定位,让loading元素定位
|
||||
el.style.position = 'relative'
|
||||
el.style.overflow = 'hidden'
|
||||
|
||||
el?.appendChild(el.instance.$el)
|
||||
}
|
||||
|
||||
// 移除元素
|
||||
const removeEl = (el) => {
|
||||
if (el.dataset['position']) {
|
||||
el.style.position = ''
|
||||
}
|
||||
|
||||
if (el.dataset['overflow']) {
|
||||
el.style.overflow = ''
|
||||
}
|
||||
|
||||
delete el.dataset['position']
|
||||
delete el.dataset['overflow']
|
||||
|
||||
let $el = el.instance.$el
|
||||
if (el?.contains($el)) {
|
||||
el?.removeChild($el)
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { useDialogueStore } from '@/store'
|
||||
import { useUserStore,useDialogueStore } from '@/store'
|
||||
// import router from '@/router'
|
||||
import { useAuth } from "@/store/auth/index.js";
|
||||
|
||||
@ -14,7 +14,7 @@ class Base {
|
||||
* 获取当前登录用户的ID
|
||||
*/
|
||||
getAccountId() {
|
||||
return userInfo.value.ID
|
||||
return useUserStore().uid
|
||||
}
|
||||
|
||||
getTalkParams() {
|
||||
|
14
src/hooks/index.js
Normal file
14
src/hooks/index.js
Normal file
@ -0,0 +1,14 @@
|
||||
// export * from './useAccessPrompt'
|
||||
// export * from './useClickEvent'
|
||||
// export * from './useConnectStatus'
|
||||
// export * from './useEventBus'
|
||||
// export * from './useFriendsMenu'
|
||||
// export * from './useGroupListMenu'
|
||||
export * from './useInject'
|
||||
export * from './useSessionMenu'
|
||||
// export * from './useSmsLock'
|
||||
export * from './useTalkRecord'
|
||||
// export * from './useThemeMode'
|
||||
// export * from './useUnreadMessage'
|
||||
export * from './useProvideUserModal'
|
||||
// export * from './useVisibilityChange'
|
11
src/hooks/useInject.js
Normal file
11
src/hooks/useInject.js
Normal file
@ -0,0 +1,11 @@
|
||||
import { inject } from 'vue'
|
||||
|
||||
export function useInject() {
|
||||
const user = inject('$user')
|
||||
|
||||
const showUserInfoModal = (uid) => {
|
||||
user(uid)
|
||||
}
|
||||
|
||||
return { showUserInfoModal }
|
||||
}
|
20
src/hooks/useProvideUserModal.js
Normal file
20
src/hooks/useProvideUserModal.js
Normal file
@ -0,0 +1,20 @@
|
||||
import { ref, provide } from 'vue'
|
||||
|
||||
export function useProvideUserModal() {
|
||||
const isShow = ref(false)
|
||||
const uid = ref(0)
|
||||
|
||||
const show = (id) => {
|
||||
uid.value = id
|
||||
isShow.value = true
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
uid.value = 0
|
||||
isShow.value = false
|
||||
}
|
||||
|
||||
provide('$user', show)
|
||||
|
||||
return { isShow, uid, show, close }
|
||||
}
|
268
src/hooks/useSessionMenu.js
Normal file
268
src/hooks/useSessionMenu.js
Normal file
@ -0,0 +1,268 @@
|
||||
import { reactive, nextTick, computed, h, inject } from 'vue'
|
||||
// import { renderIcon } from '@/utils/util'
|
||||
// import {
|
||||
// ArrowUp,
|
||||
// ArrowDown,
|
||||
// Logout,
|
||||
// Delete,
|
||||
// Clear,
|
||||
// Remind,
|
||||
// CloseRemind,
|
||||
// EditTwo,
|
||||
// IdCard
|
||||
// } from '@icon-park/vue-next'
|
||||
import { ServeTopTalkList, ServeDeleteTalkList, ServeSetNotDisturb } from '@/api/chat'
|
||||
import { useDialogueStore, useTalkStore } from '@/store'
|
||||
import { ServeSecedeGroup } from '@/api/group'
|
||||
// import { ServeDeleteContact, ServeEditContactRemark } from '@/api/contact'
|
||||
// import { NInput } from 'naive-ui'
|
||||
|
||||
export function useSessionMenu() {
|
||||
const dropdown = reactive({
|
||||
options: [],
|
||||
show: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
item: {}
|
||||
})
|
||||
|
||||
const dialogueStore = useDialogueStore()
|
||||
const talkStore = useTalkStore()
|
||||
|
||||
const user = inject('$user')
|
||||
|
||||
// 当前会话索引
|
||||
const indexName = computed(() => dialogueStore.index_name)
|
||||
|
||||
// const onContextMenu = (e: any, item: ISession) => {
|
||||
// dropdown.show = false
|
||||
// dropdown.item = Object.assign({}, item)
|
||||
// dropdown.options = []
|
||||
|
||||
// const options: any[] = []
|
||||
|
||||
// if (item.talk_type == 1) {
|
||||
// options.push({
|
||||
// icon: renderIcon(IdCard),
|
||||
// label: '好友信息',
|
||||
// key: 'info'
|
||||
// })
|
||||
|
||||
// options.push({
|
||||
// icon: renderIcon(EditTwo),
|
||||
// label: '修改备注',
|
||||
// key: 'remark'
|
||||
// })
|
||||
// }
|
||||
|
||||
// options.push({
|
||||
// icon: renderIcon(item.is_top ? ArrowDown : ArrowUp),
|
||||
// label: item.is_top ? '取消置顶' : '会话置顶',
|
||||
// key: 'top'
|
||||
// })
|
||||
|
||||
// options.push({
|
||||
// icon: renderIcon(item.is_disturb ? Remind : CloseRemind),
|
||||
// label: item.is_disturb ? '关闭免打扰' : '开启免打扰',
|
||||
// key: 'disturb'
|
||||
// })
|
||||
|
||||
// options.push({
|
||||
// icon: renderIcon(Clear),
|
||||
// label: '移除会话',
|
||||
// key: 'remove'
|
||||
// })
|
||||
|
||||
// if (item.talk_type == 1) {
|
||||
// options.push({
|
||||
// icon: renderIcon(Delete),
|
||||
// label: '删除好友',
|
||||
// key: 'delete_contact'
|
||||
// })
|
||||
// } else {
|
||||
// options.push({
|
||||
// icon: renderIcon(Logout),
|
||||
// label: '退出群聊',
|
||||
// key: 'signout_group'
|
||||
// })
|
||||
// }
|
||||
|
||||
// dropdown.options = [...options]
|
||||
|
||||
// nextTick(() => {
|
||||
// dropdown.show = true
|
||||
// dropdown.x = e.clientX
|
||||
// dropdown.y = e.clientY
|
||||
// })
|
||||
|
||||
// e.preventDefault()
|
||||
// }
|
||||
|
||||
const onCloseContextMenu = () => {
|
||||
dropdown.show = false
|
||||
dropdown.item = {}
|
||||
}
|
||||
|
||||
const onDeleteTalk = (index_name = '') => {
|
||||
talkStore.delItem(index_name)
|
||||
|
||||
index_name === indexName.value && dialogueStore.$reset()
|
||||
}
|
||||
|
||||
const onUserInfo = (item) => {
|
||||
user(item.receiver_id)
|
||||
}
|
||||
|
||||
// 移除会话
|
||||
const onRemoveTalk = (item) => {
|
||||
ServeDeleteTalkList({
|
||||
list_id: item.id
|
||||
}).then(({ code }) => {
|
||||
if (code == 200) {
|
||||
onDeleteTalk(item.index_name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 设置消息免打扰
|
||||
const onSetDisturb = (item) => {
|
||||
ServeSetNotDisturb({
|
||||
talk_type: item.talk_type,
|
||||
receiver_id: item.receiver_id,
|
||||
is_disturb: item.is_disturb == 0 ? 1 : 0
|
||||
}).then(({ code, message }) => {
|
||||
if (code == 200) {
|
||||
window['$message'].success('设置成功!')
|
||||
talkStore.updateItem({
|
||||
index_name: item.index_name,
|
||||
is_disturb: item.is_disturb == 0 ? 1 : 0
|
||||
})
|
||||
} else {
|
||||
window['$message'].error(message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 置顶会话
|
||||
const onToTopTalk = (item) => {
|
||||
if (item.is_top == 0 && talkStore.topItems.length >= 18) {
|
||||
return window['$message'].info('置顶最多不能超过18个会话')
|
||||
}
|
||||
|
||||
ServeTopTalkList({
|
||||
list_id: item.id,
|
||||
type: item.is_top == 0 ? 1 : 2
|
||||
}).then(({ code, message }) => {
|
||||
if (code == 200) {
|
||||
talkStore.updateItem({
|
||||
index_name: item.index_name,
|
||||
is_top: item.is_top == 0 ? 1 : 0
|
||||
})
|
||||
} else {
|
||||
window['$message'].error(message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// // 移除联系人
|
||||
// const onDeleteContact = (item) => {
|
||||
// const name = item.remark || item.name
|
||||
|
||||
// window['$dialog'].create({
|
||||
// showIcon: false,
|
||||
// title: `删除 [${name}] 联系人?`,
|
||||
// content: '删除后不再接收对方任何消息。',
|
||||
// positiveText: '确定',
|
||||
// negativeText: '取消',
|
||||
// onPositiveClick: () => {
|
||||
// ServeDeleteContact({
|
||||
// friend_id: item.receiver_id
|
||||
// }).then(({ code, message }) => {
|
||||
// if (code == 200) {
|
||||
// window['$message'].success('删除联系人成功')
|
||||
// onDeleteTalk(item.index_name)
|
||||
// } else {
|
||||
// window['$message'].error(message)
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
|
||||
// 退出群聊
|
||||
const onSignOutGroup = (item) => {
|
||||
window['$dialog'].create({
|
||||
showIcon: false,
|
||||
title: `退出 [${item.name}] 群聊?`,
|
||||
content: '退出后不再接收此群的任何消息。',
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => {
|
||||
ServeSecedeGroup({
|
||||
group_id: item.receiver_id
|
||||
}).then(({ code, message }) => {
|
||||
if (code == 200) {
|
||||
window['$message'].success('已退出群聊')
|
||||
onDeleteTalk(item.index_name)
|
||||
} else {
|
||||
window['$message'].error(message)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// const onChangeRemark = (item) => {
|
||||
// let remark = ''
|
||||
// window['$dialog'].create({
|
||||
// showIcon: false,
|
||||
// title: '修改备注',
|
||||
// content: () => {
|
||||
// return h(NInput, {
|
||||
// defaultValue: item.remark,
|
||||
// placeholder: '请输入备注信息',
|
||||
// style: { marginTop: '20px' },
|
||||
// onInput: (value) => (remark = value),
|
||||
// autofocus: true
|
||||
// })
|
||||
// },
|
||||
// negativeText: '取消',
|
||||
// positiveText: '修改备注',
|
||||
// onPositiveClick: () => {
|
||||
// ServeEditContactRemark({
|
||||
// friend_id: item.receiver_id,
|
||||
// remark: remark
|
||||
// }).then(({ code, message }) => {
|
||||
// if (code == 200) {
|
||||
// window['$message'].success('备注成功')
|
||||
// talkStore.updateItem({
|
||||
// index_name: item.index_name,
|
||||
// remark: remark
|
||||
// })
|
||||
// } else {
|
||||
// window['$message'].error(message)
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
|
||||
// 会话列表右键菜单回调事件
|
||||
const onContextMenuTalkHandle = (key) => {
|
||||
// 注册回调事件
|
||||
const evnets = {
|
||||
info: onUserInfo,
|
||||
top: onToTopTalk,
|
||||
remove: onRemoveTalk,
|
||||
disturb: onSetDisturb,
|
||||
signout_group: onSignOutGroup,
|
||||
delete_contact: onDeleteContact,
|
||||
remark: onChangeRemark
|
||||
}
|
||||
|
||||
dropdown.show = false
|
||||
evnets[key] && evnets[key](dropdown.item)
|
||||
}
|
||||
|
||||
return { dropdown, onCloseContextMenu, onContextMenuTalkHandle, onToTopTalk }
|
||||
}
|
146
src/hooks/useTalkRecord.js
Normal file
146
src/hooks/useTalkRecord.js
Normal file
@ -0,0 +1,146 @@
|
||||
import { reactive, computed, nextTick } from 'vue'
|
||||
import { ServeTalkRecords } from '@/api/chat'
|
||||
import { useDialogueStore } from '@/store'
|
||||
import { formatTalkRecord } from '@/utils/talk'
|
||||
import { addClass, removeClass } from '@/utils/dom'
|
||||
|
||||
export const useTalkRecord = (uid) => {
|
||||
const dialogueStore = useDialogueStore()
|
||||
|
||||
const records = computed(() => dialogueStore.records)
|
||||
|
||||
const location = reactive({
|
||||
msgid: '',
|
||||
num: 0
|
||||
})
|
||||
|
||||
const loadConfig = reactive({
|
||||
receiver_id: 0,
|
||||
talk_type: 0,
|
||||
status: 0,
|
||||
cursor: 0
|
||||
})
|
||||
|
||||
const onJumpMessage = (msgid) => {
|
||||
const element = document.getElementById(msgid)
|
||||
if (!element) {
|
||||
if (location.msgid == '') {
|
||||
location.msgid = msgid
|
||||
location.num = 3
|
||||
} else {
|
||||
location.num--
|
||||
|
||||
if (location.num === 0) {
|
||||
location.msgid = ''
|
||||
location.num = 0
|
||||
window['$message'].info('仅支持查看最近300条的记录')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const el = document.getElementById('imChatPanel')
|
||||
|
||||
return el?.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
|
||||
location.msgid = ''
|
||||
location.num = 0
|
||||
|
||||
element?.scrollIntoView({
|
||||
behavior: 'smooth'
|
||||
})
|
||||
|
||||
addClass(element, 'border')
|
||||
|
||||
setTimeout(() => {
|
||||
element && removeClass(element, 'border')
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
// 加载数据列表
|
||||
const load = async (params) => {
|
||||
const request = {
|
||||
talk_type: params.talk_type,
|
||||
receiver_id: params.receiver_id,
|
||||
cursor: loadConfig.cursor,
|
||||
limit: 30
|
||||
}
|
||||
|
||||
loadConfig.status = 0
|
||||
|
||||
let scrollHeight = 0
|
||||
const el = document.getElementById('imChatPanel')
|
||||
if (el) {
|
||||
scrollHeight = el.scrollHeight
|
||||
}
|
||||
|
||||
const { data, code } = await ServeTalkRecords(request)
|
||||
if (code != 200) {
|
||||
return (loadConfig.status = 1)
|
||||
}
|
||||
|
||||
// 防止对话切换过快,数据渲染错误
|
||||
if (
|
||||
request.talk_type != loadConfig.talk_type ||
|
||||
request.receiver_id != loadConfig.receiver_id
|
||||
) {
|
||||
return (location.msgid = '')
|
||||
}
|
||||
|
||||
const items = (data.items || []).map((item) => formatTalkRecord(uid, item))
|
||||
|
||||
if (request.cursor == 0) {
|
||||
// 判断是否是初次加载
|
||||
dialogueStore.clearDialogueRecord()
|
||||
}
|
||||
|
||||
dialogueStore.unshiftDialogueRecord(items.reverse())
|
||||
|
||||
loadConfig.status = items.length >= request.limit ? 1 : 2
|
||||
|
||||
loadConfig.cursor = data.cursor
|
||||
|
||||
nextTick(() => {
|
||||
const el = document.getElementById('imChatPanel')
|
||||
|
||||
if (el) {
|
||||
if (request.cursor == 0) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
|
||||
setTimeout(() => {
|
||||
el.scrollTop = el.scrollHeight + 1000
|
||||
}, 50)
|
||||
} else {
|
||||
el.scrollTop = el.scrollHeight - scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
if (location.msgid) {
|
||||
onJumpMessage(location.msgid)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onRefreshLoad = () => {
|
||||
if (loadConfig.status == 1) {
|
||||
load({
|
||||
receiver_id: loadConfig.receiver_id,
|
||||
talk_type: loadConfig.talk_type,
|
||||
limit: 30
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onLoad = (params) => {
|
||||
loadConfig.cursor = 0
|
||||
loadConfig.receiver_id = params.receiver_id
|
||||
loadConfig.talk_type = params.talk_type
|
||||
|
||||
load(params)
|
||||
}
|
||||
|
||||
return { loadConfig, records, onLoad, onRefreshLoad, onJumpMessage }
|
||||
}
|
@ -11,6 +11,7 @@ import xLoaderror from '@/components/x-loaderror/index.vue'
|
||||
import { vLoading } from "@/components/x-loading/index.js"
|
||||
import messagePopup from '@/components/x-message/useMessagePopup'
|
||||
import pageAnimation from '@/components/page-animation/index.vue'
|
||||
import * as plugins from './plugins'
|
||||
const {showMessage}=messagePopup()
|
||||
dayjs.locale('zh-cn')
|
||||
if (import.meta.env.VITE_SHOW_CONSOLE){
|
||||
@ -19,6 +20,8 @@ if (import.meta.env.VITE_SHOW_CONSOLE){
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
app.use(tmui,{...config})
|
||||
plugins.setPinia(app)
|
||||
plugins.setComponents(app)
|
||||
app.directive("loading", vLoading)
|
||||
app.mixin(pageAnimation)
|
||||
app.component('x-loaderror',xLoaderror)
|
||||
|
@ -14,6 +14,14 @@
|
||||
"enablePullDownRefresh":false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/dialog/index",
|
||||
"type": "page",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"enablePullDownRefresh":false
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
|
140
src/pages/dialog/components/chatItem.vue
Normal file
140
src/pages/dialog/components/chatItem.vue
Normal file
@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div>
|
||||
<wd-swipe-action >
|
||||
<div @click="cellClick" :class="['chatItem',props.data.is_top ===1?'isTop':'' ]">
|
||||
<div class="avatarImg">
|
||||
<tm-badge :count="props.data.unread_num" :maxCount="99" color="#D03050">
|
||||
<tm-image preview :width="96" :height="96" :round="12" :src="props.data.avatar"></tm-image>
|
||||
</tm-badge>
|
||||
</div>
|
||||
<div class="chatInfo">
|
||||
<div class="chatInfo_1">
|
||||
<div class="flex items-center">
|
||||
<div class="text-[#000000] text-[32rpx] font-bold opacity-90">{{ props.data.name }}</div>
|
||||
<div>
|
||||
<div class="companyTag">公司</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[#000000] text-[28rpx] font-medium opacity-26">{{ beautifyTime(props.data.updated_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="chatInfo_2 w-full mr-[6rpx]">
|
||||
<div class="w-full chatInfo_2_1 textEllipsis">{{ props.data.msg_text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #right>
|
||||
<div class="flex flex-row flex-row-center-end">
|
||||
<div @click="handleTop" class="w-[156rpx] h-[154rpx] text-[#ffffff] bg-[#F09F1F] flex items-center justify-center">{{props.data.is_top===1?'取消置顶':'置顶'}}</div>
|
||||
<div class="w-[156rpx] h-[154rpx] text-[#ffffff] bg-[#CF3050] flex items-center justify-center">删除</div>
|
||||
</div>
|
||||
</template>
|
||||
</wd-swipe-action>
|
||||
<div v-if="props.index !== talkStore.talkItems.length - 1" class="divider"></div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, reactive, defineProps } from "vue"
|
||||
import dayjs from "dayjs";
|
||||
import { beautifyTime } from '@/utils/datetime'
|
||||
import { useTalkStore } from '@/store'
|
||||
import { useSessionMenu } from '@/hooks'
|
||||
|
||||
const talkStore = useTalkStore()
|
||||
const {
|
||||
onToTopTalk
|
||||
} = useSessionMenu()
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object,
|
||||
default: {},
|
||||
required: true,
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
default: -1,
|
||||
required: true,
|
||||
}
|
||||
});
|
||||
|
||||
const cellClick = () => {
|
||||
console.log(props.data);
|
||||
|
||||
}
|
||||
|
||||
const handleTop = () => {
|
||||
console.log(props.data,1);
|
||||
onToTopTalk(props.data)
|
||||
}
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.chatItem {
|
||||
width: 100%;
|
||||
height: 154rpx;
|
||||
padding: 30rpx 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
&.isTop{
|
||||
background-color: #F3F3F3;
|
||||
}
|
||||
}
|
||||
|
||||
.avatarImg {
|
||||
height: 96rpx;
|
||||
width: 96rpx;
|
||||
}
|
||||
|
||||
.chatInfo {
|
||||
flex: 1;
|
||||
margin-left: 20rpx;
|
||||
}
|
||||
|
||||
.chatInfo_1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.chatInfo_2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
|
||||
.chatInfo_2_1 {
|
||||
font-size: 28rpx;
|
||||
color: #000000;
|
||||
opacity: 40%;
|
||||
|
||||
}
|
||||
|
||||
.companyTag {
|
||||
width: 76rpx;
|
||||
height: 38rpx;
|
||||
border: 1px solid #7A58DE;
|
||||
font-size: 24rpx;
|
||||
text-align: center;
|
||||
border-radius: 6rpx;
|
||||
color: #7A58DE;
|
||||
font-weight: bold;
|
||||
margin-left: 12rpx;
|
||||
}
|
||||
|
||||
.textEllipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.divider {
|
||||
background-color: rgba(243, 243, 243, 1);
|
||||
height: 1rpx;
|
||||
margin: 0 18rpx;
|
||||
}
|
||||
</style>
|
143
src/pages/dialog/index.vue
Normal file
143
src/pages/dialog/index.vue
Normal file
@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="outer-layer">
|
||||
<div>
|
||||
<tm-navbar :hideBack="false" hideHome title="" :leftWidth="220" >
|
||||
<div class="flex flex-col items-center justify-center" >
|
||||
<div class="text-[34rpx] font-bold" >{{ talkParams.username }}</div>
|
||||
<div v-if="true" class="text-[24rpx] text-[#999999]" >公司群</div>
|
||||
</div>
|
||||
<template v-slot:right>
|
||||
<div class="mr-[36rpx]">
|
||||
<tm-icon color="rgb(51, 51, 51)" :font-size="36" name="tmicon-gengduo"></tm-icon>
|
||||
</div>
|
||||
</template>
|
||||
</tm-navbar>
|
||||
</div>
|
||||
<div class="root">
|
||||
<div class="dialogBox" >
|
||||
<!-- <div v-if="loadConfig.status == 0" class="h-[240rpx] flex items-center justify-center flex-col" >
|
||||
<wd-loading />
|
||||
<div class="text-[#959598] mt-[20rpx] text-[28rpx]" > 正在加载中... </div>
|
||||
</div>
|
||||
<div v-else-if="loadConfig.status == 1" @click="onRefreshLoad" >查看更多消息 ...</div>
|
||||
<span v-else class="no-more"> 没有更多消息了 </span> -->
|
||||
<div
|
||||
class="message-item"
|
||||
v-for="(item, index) in records"
|
||||
:key="item.msg_id"
|
||||
:id="item.msg_id"
|
||||
>
|
||||
<!-- 系统消息 -->
|
||||
<div v-if="item.msg_type >= 1000" class="message-box">
|
||||
<component
|
||||
:is="MessageComponents[item.msg_type] || 'unknown-message'"
|
||||
:extra="item.extra"
|
||||
:data="item"
|
||||
/>
|
||||
</div>
|
||||
<!-- 撤回消息 -->
|
||||
<div v-else-if="item.is_revoke == 1" class="message-box">
|
||||
<revoke-message
|
||||
:login_uid="uid"
|
||||
:user_id="item.user_id"
|
||||
:nickname="item.nickname"
|
||||
:talk_type="item.talk_type"
|
||||
:datetime="item.created_at"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footBox" >
|
||||
<div class="mt-[16rpx] ml-[32rpx] mr-[32rpx] flex items-center justify-between" >
|
||||
<div class="w-[534rpx]" >
|
||||
<tm-input :height="72" placeholder="" ></tm-input>
|
||||
</div>
|
||||
<tm-image :width="52" :height="52" :round="12" :src="smile"></tm-image>
|
||||
<tm-image :width="52" :height="52" :round="12" :src="addCircleGray"></tm-image>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref,reactive,watch,computed,onMounted } from 'vue';
|
||||
import { useChatList } from "@/store/chatList/index.js";
|
||||
import {useAuth} from "@/store/auth";
|
||||
import { useUserStore, useDialogueStore, useUploadsStore } from '@/store'
|
||||
import addCircleGray from "@/static/image/chatList/addCircleGray.png";
|
||||
import { MessageComponents, ForwardableMessageType } from '@/constant/message'
|
||||
import smile from "@/static/image/chatList/smile@2x.png";
|
||||
import { useInject, useTalkRecord } from '@/hooks'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const dialogueStore = useDialogueStore()
|
||||
|
||||
const talkParams = reactive({
|
||||
uid: computed(() => userStore.uid),
|
||||
index_name: computed(() => dialogueStore.index_name),
|
||||
type: computed(() => dialogueStore.talk.talk_type),
|
||||
receiver_id: computed(() => dialogueStore.talk.receiver_id),
|
||||
username: computed(() => dialogueStore.talk.username),
|
||||
online: computed(() => dialogueStore.online),
|
||||
keyboard: computed(() => dialogueStore.keyboard),
|
||||
num: computed(() => dialogueStore.members.length)
|
||||
})
|
||||
|
||||
const { loadConfig, records, onLoad, onRefreshLoad, onJumpMessage } = useTalkRecord(talkParams.uid)
|
||||
watch(() => records, (newValue, oldValue) => {
|
||||
console.log(newValue);
|
||||
|
||||
},{deep:true,immediate:true})
|
||||
|
||||
watch(() => talkParams.uid, (newValue, oldValue) => {
|
||||
let objT = {
|
||||
uid: talkParams.uid,
|
||||
talk_type: talkParams.type,
|
||||
receiver_id: talkParams.receiver_id,
|
||||
index_name: talkParams.index_name
|
||||
}
|
||||
onLoad({ ...objT, limit: 30 })
|
||||
},{immediate:true})
|
||||
|
||||
onMounted(() => {
|
||||
let objT = {
|
||||
uid: talkParams.uid,
|
||||
talk_type: talkParams.type,
|
||||
receiver_id: talkParams.receiver_id,
|
||||
index_name: talkParams.index_name
|
||||
}
|
||||
onLoad({ ...objT, limit: 30 })
|
||||
})
|
||||
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.outer-layer {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
background-image: url("@/static/image/clockIn/z3280@3x.png");
|
||||
background-size: cover;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.root {
|
||||
flex: 1;
|
||||
padding: 20rpx 32rpx;
|
||||
}
|
||||
.searchRoot {
|
||||
background-color: #fff;
|
||||
padding: 22rpx 18rpx;
|
||||
}
|
||||
.contentRoot {
|
||||
margin-top: 20rpx;
|
||||
background-color: #fff;
|
||||
}
|
||||
.footBox {
|
||||
height: 162rpx;
|
||||
background-color: #fff;
|
||||
}
|
||||
.dialogBox{
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
</style>
|
162
src/pages/index/components/chatItem.vue
Normal file
162
src/pages/index/components/chatItem.vue
Normal file
@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div>
|
||||
<wd-swipe-action>
|
||||
<div @click="cellClick" :class="['chatItem', props.data.is_top === 1 ? 'isTop' : '']">
|
||||
<div class="avatarImg">
|
||||
<tm-badge :count="props.data.unread_num" :maxCount="99" color="#D03050">
|
||||
<tm-image preview :width="96" :height="96" :round="12" :src="props.data.avatar"></tm-image>
|
||||
</tm-badge>
|
||||
</div>
|
||||
<div class="chatInfo">
|
||||
<div class="chatInfo_1">
|
||||
<div class="flex items-center">
|
||||
<div class="text-[#000000] text-[32rpx] font-bold opacity-90">{{ props.data.name }}</div>
|
||||
<div>
|
||||
<div class="companyTag">公司</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[#000000] text-[28rpx] font-medium opacity-26">{{ beautifyTime(props.data.updated_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="chatInfo_2 w-full mr-[6rpx]">
|
||||
<div class="w-full chatInfo_2_1 textEllipsis">{{ props.data.msg_text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #right>
|
||||
<div class="flex flex-row flex-row-center-end">
|
||||
<div @click="handleTop"
|
||||
class="w-[156rpx] h-[154rpx] text-[#ffffff] bg-[#F09F1F] flex items-center justify-center">
|
||||
{{ props.data.is_top === 1 ? '取消置顶' : '置顶' }}</div>
|
||||
<div class="w-[156rpx] h-[154rpx] text-[#ffffff] bg-[#CF3050] flex items-center justify-center">删除</div>
|
||||
</div>
|
||||
</template>
|
||||
</wd-swipe-action>
|
||||
<div v-if="props.index !== talkStore.talkItems.length - 1" class="divider"></div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, reactive, defineProps } from "vue"
|
||||
import dayjs from "dayjs";
|
||||
import { beautifyTime } from '@/utils/datetime'
|
||||
import { ServeClearTalkUnreadNum } from '@/api/chat'
|
||||
import { useTalkStore, useDialogueStore } from '@/store'
|
||||
import { useSessionMenu } from '@/hooks'
|
||||
|
||||
const talkStore = useTalkStore()
|
||||
const {
|
||||
onToTopTalk
|
||||
} = useSessionMenu()
|
||||
const dialogueStore = useDialogueStore()
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object,
|
||||
default: {},
|
||||
required: true,
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
default: -1,
|
||||
required: true,
|
||||
}
|
||||
});
|
||||
|
||||
const cellClick = () => {
|
||||
console.log(props.data);
|
||||
// 更新编辑信息
|
||||
dialogueStore.setDialogue(props.data)
|
||||
|
||||
// 清空消息未读数
|
||||
if (props.data.unread_num > 0) {
|
||||
ServeClearTalkUnreadNum({
|
||||
talk_type: props.data.talk_type,
|
||||
receiver_id: props.data.receiver_id
|
||||
}).then(() => {
|
||||
talkStore.updateItem({
|
||||
index_name: props.data.index_name,
|
||||
unread_num: 0
|
||||
})
|
||||
})
|
||||
}
|
||||
uni.navigateTo({
|
||||
url: '/pages/dialog/index',
|
||||
})
|
||||
}
|
||||
|
||||
const handleTop = () => {
|
||||
console.log(props.data, 1);
|
||||
onToTopTalk(props.data)
|
||||
}
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.chatItem {
|
||||
width: 100%;
|
||||
height: 154rpx;
|
||||
padding: 30rpx 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&.isTop {
|
||||
background-color: #F3F3F3;
|
||||
}
|
||||
}
|
||||
|
||||
.avatarImg {
|
||||
height: 96rpx;
|
||||
width: 96rpx;
|
||||
}
|
||||
|
||||
.chatInfo {
|
||||
flex: 1;
|
||||
margin-left: 20rpx;
|
||||
}
|
||||
|
||||
.chatInfo_1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.chatInfo_2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
|
||||
.chatInfo_2_1 {
|
||||
font-size: 28rpx;
|
||||
color: #000000;
|
||||
opacity: 40%;
|
||||
|
||||
}
|
||||
|
||||
.companyTag {
|
||||
width: 76rpx;
|
||||
height: 38rpx;
|
||||
border: 1px solid #7A58DE;
|
||||
font-size: 24rpx;
|
||||
text-align: center;
|
||||
border-radius: 6rpx;
|
||||
color: #7A58DE;
|
||||
font-weight: bold;
|
||||
margin-left: 12rpx;
|
||||
}
|
||||
|
||||
.textEllipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.divider {
|
||||
background-color: rgba(243, 243, 243, 1);
|
||||
height: 1rpx;
|
||||
margin: 0 18rpx;
|
||||
}
|
||||
</style>
|
@ -1,42 +1,66 @@
|
||||
<template>
|
||||
<div class="outer-layer">
|
||||
<div>
|
||||
<tm-navbar :hideBack="false" hideHome :title="123"> </tm-navbar>
|
||||
<tm-navbar :hideBack="false" hideHome title="" :leftWidth="320" >
|
||||
<template v-slot:left>
|
||||
<div class="flex items-center ml-[48rpx]" >
|
||||
<tm-image :width="72" :height="72" :round="12" :src="userStore.avatar"></tm-image>
|
||||
<div class="ml-[24rpx] text-[36rpx] font-bold">{{ userStore.nickname }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:right>
|
||||
<div class="mr-[48rpx]">
|
||||
<tm-image :width="41" :height="41" :round="12" :src="addCircle"></tm-image>
|
||||
</div>
|
||||
</template>
|
||||
</tm-navbar>
|
||||
</div>
|
||||
<div class="root">
|
||||
<div class="searchRoot">
|
||||
<tm-input placeholder="请输入…" color="#F9F9FD" :round="1" prefix="tmicon-search" prefixColor="#46299D" ></tm-input>
|
||||
</div>
|
||||
<div class="contentRoot">
|
||||
<div class="chatItem" >
|
||||
<div class="avatarImg">
|
||||
<tm-image preview :width="96" :height="96" :src="userInfo.Avatar"></tm-image>
|
||||
</div>
|
||||
<div class="chatInfo" >
|
||||
<div class="chatInfo_1" >
|
||||
<div class="flex items-center">
|
||||
<div class="text-[#000000] text-[32rpx] font-bold opacity-90" >泰丰国际(600)</div>
|
||||
<div>
|
||||
<div class="companyTag" >公司</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[#000000] text-[28rpx] font-medium opacity-26" >12:00</div>
|
||||
</div>
|
||||
<div class="chatInfo_2 w-full mr-[6rpx]">
|
||||
<div class="w-full chatInfo_2_1 textEllipsis" >欢迎加入欢迎加入欢迎加入欢迎加入欢迎加入欢迎加入欢迎加入欢迎加入</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<chatItem
|
||||
v-for="(item,index) in items"
|
||||
:key="item.index_name"
|
||||
:index="index"
|
||||
:data="item"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref,watch,computed } from 'vue';
|
||||
import { useChatList } from "@/store/chatList/index.js";
|
||||
import {useAuth} from "@/store/auth";
|
||||
import { useTalkStore,useUserStore } from '@/store'
|
||||
import chatItem from './components/chatItem.vue';
|
||||
import addCircle from "@/static/image/chatList/addCircle.png";
|
||||
|
||||
const talkStore = useTalkStore()
|
||||
const userStore = useUserStore()
|
||||
const {userInfo}=useAuth()
|
||||
|
||||
const topItems = computed(() => talkStore.topItems)
|
||||
const items = computed(() => {
|
||||
// if (searchKeyword.value.length === 0) {
|
||||
return talkStore.talkItems
|
||||
// }
|
||||
|
||||
// return talkStore.talkItems.filter((item) => {
|
||||
// let keyword = item.remark || item.name
|
||||
|
||||
// return keyword.toLowerCase().indexOf(searchKeyword.value.toLowerCase()) != -1
|
||||
// })
|
||||
})
|
||||
|
||||
|
||||
watch(() => talkStore, (newValue, oldValue) => {
|
||||
console.log(talkStore);
|
||||
|
||||
},{deep:true,immediate:true})
|
||||
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.outer-layer {
|
||||
@ -60,52 +84,5 @@ const {userInfo}=useAuth()
|
||||
margin-top: 20rpx;
|
||||
background-color: #fff;
|
||||
}
|
||||
.chatItem{
|
||||
width: 100%;
|
||||
padding: 30rpx 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.avatarImg{
|
||||
height: 96rpx;
|
||||
width: 96rpx;
|
||||
}
|
||||
.chatInfo{
|
||||
flex:1;
|
||||
margin-left: 20rpx;
|
||||
}
|
||||
.chatInfo_1{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.chatInfo_2{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
.chatInfo_2_1{
|
||||
font-size: 28rpx;
|
||||
color: #000000;
|
||||
opacity: 40%;
|
||||
|
||||
}
|
||||
.companyTag{
|
||||
width: 76rpx;
|
||||
height: 38rpx;
|
||||
border: 1px solid #7A58DE;
|
||||
font-size: 24rpx;
|
||||
text-align: center;
|
||||
border-radius: 6rpx;
|
||||
color: #7A58DE;
|
||||
font-weight: bold;
|
||||
}
|
||||
.textEllipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
</style>
|
||||
|
11
src/plugins/component.js
Normal file
11
src/plugins/component.js
Normal file
@ -0,0 +1,11 @@
|
||||
import { setComponents as Components } from '@/components/talk/message'
|
||||
import Avatar from '@/components/base/Avatar.vue'
|
||||
|
||||
export { setupDirective } from '@/directive'
|
||||
|
||||
// 注册全局消息组件
|
||||
export function setComponents(app) {
|
||||
Components(app)
|
||||
|
||||
app.component('im-avatar', Avatar)
|
||||
}
|
3
src/plugins/index.js
Normal file
3
src/plugins/index.js
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './component'
|
||||
export * from './ws-socket'
|
||||
export * from './pinia'
|
9
src/plugins/pinia.js
Normal file
9
src/plugins/pinia.js
Normal file
@ -0,0 +1,9 @@
|
||||
import { createPinia } from 'pinia'
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
|
||||
export function setPinia(app) {
|
||||
const pinia = createPinia()
|
||||
pinia.use(piniaPluginPersistedstate)
|
||||
|
||||
app.use(pinia)
|
||||
}
|
BIN
src/static/image/chatList/addCircle.png
Normal file
BIN
src/static/image/chatList/addCircle.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.6 KiB |
BIN
src/static/image/chatList/addCircleGray.png
Normal file
BIN
src/static/image/chatList/addCircleGray.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.7 KiB |
BIN
src/static/image/chatList/avatar.png
Normal file
BIN
src/static/image/chatList/avatar.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
BIN
src/static/image/chatList/notify.png
Normal file
BIN
src/static/image/chatList/notify.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 11 KiB |
BIN
src/static/image/chatList/smile@2x.png
Normal file
BIN
src/static/image/chatList/smile@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.0 KiB |
@ -3,7 +3,8 @@ import {uniStorage} from "@/utils/uniStorage.js"
|
||||
|
||||
import {ref} from 'vue'
|
||||
export const useAuth = createGlobalState(() => {
|
||||
const token = useStorage('token', '', uniStorage)
|
||||
// const token = useStorage('token', '', uniStorage)
|
||||
const token = ref('30119d9978a6f3321fb4779c0040e997df4dd0dd0cf6b71119657617d2249ed783f940b0050d5be7e758740ea467afdf3eeb4d28fb5ea234af60ebe51fb218ffea38d3362de44912166520e87a6f38da2162e348845fabbb0db3604d4a2e7e543f680408fdbb166cbc70418235831e0b95aed8016b4a3b75feeec68212e4824da6e28c747409384ce136db6b9cd6e688cc40db73794e69f51be0920811cc437e51ca29fc82fccc1e98cb39b2577ef5b574460c853336d6afbe13149711d5e3fe')
|
||||
const refreshToken = useStorage('refreshToken', '', uniStorage)
|
||||
const userInfo = useStorage('userInfo', {}, uniStorage)
|
||||
const leaderList = useStorage('leaderList', [], uniStorage)
|
||||
|
@ -4,5 +4,5 @@ export * from '@/store/modules/talk'
|
||||
// export * from '@/store/modules/editor'
|
||||
export * from '@/store/modules/dialogue'
|
||||
// export * from '@/store/modules/editor-draft'
|
||||
// export * from '@/store/modules/uploads'
|
||||
export * from '@/store/modules/uploads'
|
||||
// export * from '@/store/modules/note'
|
||||
|
@ -22,9 +22,15 @@ export const useTalkStore = defineStore('talk', {
|
||||
|
||||
// 对话列表
|
||||
talkItems: (state) => {
|
||||
return state.items.sort((a, b) => {
|
||||
let topList = state.items.filter((item) => item.is_top == 1)
|
||||
let listT = state.items.filter(v=>v.is_top !== 1)
|
||||
let listP = topList.sort((a, b) => {
|
||||
return ttime(b.updated_at) - ttime(a.updated_at)
|
||||
})
|
||||
listT = listT.sort((a, b) => {
|
||||
return ttime(b.updated_at) - ttime(a.updated_at)
|
||||
})
|
||||
return [...listP,...listT]
|
||||
},
|
||||
|
||||
// 消息未读数总计
|
||||
@ -65,10 +71,9 @@ export const useTalkStore = defineStore('talk', {
|
||||
// 更新对话消息
|
||||
updateMessage(params) {
|
||||
const item = this.items.find((item) => item.index_name === params.index_name)
|
||||
|
||||
if (item) {
|
||||
item.unread_num++
|
||||
// item.msg_text = params.msg_text
|
||||
item.msg_text = params.msg_text
|
||||
item.updated_at = params.updated_at
|
||||
}
|
||||
},
|
||||
|
125
src/store/modules/uploads.ts
Normal file
125
src/store/modules/uploads.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ServeFindFileSplitInfo, ServeFileSubareaUpload } from '@/api/upload'
|
||||
import { ServeSendTalkFile } from '@/api/chat'
|
||||
|
||||
// @ts-ignore
|
||||
const message = window.$message
|
||||
|
||||
// 处理拆分上传文件
|
||||
function fileSlice(file: File, uploadId: string, eachSize: number) {
|
||||
const splitNum = Math.ceil(file.size / eachSize) // 分片总数
|
||||
const items: FormData[] = []
|
||||
|
||||
// 处理每个分片的上传操作
|
||||
for (let i = 0; i < splitNum; i++) {
|
||||
const start = i * eachSize
|
||||
const end = Math.min(file.size, start + eachSize)
|
||||
|
||||
const form = new FormData()
|
||||
form.append('file', file.slice(start, end))
|
||||
form.append('upload_id', uploadId)
|
||||
form.append('split_index', `${i + 1}`)
|
||||
form.append('split_num', `${splitNum}`)
|
||||
|
||||
items.push(form)
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
export const useUploadsStore = defineStore('uploads', {
|
||||
state: () => {
|
||||
return {
|
||||
isShow: false,
|
||||
items: []
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
successCount: (state) => {
|
||||
return state.items.filter((item: any) => {
|
||||
return item.status === 2
|
||||
}).length
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
close() {
|
||||
this.isShow = false
|
||||
},
|
||||
|
||||
// 初始化上传
|
||||
initUploadFile(file: File, talkType: number, receiverId: number, username: string) {
|
||||
ServeFindFileSplitInfo({
|
||||
file_name: file.name,
|
||||
file_size: file.size
|
||||
}).then((res) => {
|
||||
if (res.code == 200) {
|
||||
const { upload_id, split_size } = res.data
|
||||
|
||||
// @ts-ignore
|
||||
this.items.unshift({
|
||||
file: file,
|
||||
talk_type: talkType,
|
||||
receiver_id: receiverId,
|
||||
upload_id: upload_id,
|
||||
uploadIndex: 0,
|
||||
percentage: 0,
|
||||
status: 0, // 文件上传状态 0:等待上传 1:上传中 2:上传完成 3:网络异常
|
||||
files: fileSlice(file, upload_id, split_size),
|
||||
avatar: '',
|
||||
username: username
|
||||
})
|
||||
|
||||
this.triggerUpload(upload_id)
|
||||
this.isShow = true
|
||||
} else {
|
||||
message.error(res.message)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 获取分片文件数组索引
|
||||
findItem(uploadId: string): any {
|
||||
return this.items.find((item: any) => item.upload_id === uploadId)
|
||||
},
|
||||
|
||||
// 触发上传
|
||||
triggerUpload(uploadId: string) {
|
||||
const item = this.findItem(uploadId)
|
||||
|
||||
const form = item.files[item.uploadIndex]
|
||||
|
||||
item.status = 1
|
||||
|
||||
ServeFileSubareaUpload(form)
|
||||
.then((res) => {
|
||||
if (res.code == 200) {
|
||||
item.uploadIndex++
|
||||
|
||||
if (item.uploadIndex === item.files.length) {
|
||||
item.status = 2
|
||||
item.percentage = 100
|
||||
this.sendUploadMessage(item)
|
||||
} else {
|
||||
const percentage = (item.uploadIndex / item.files.length) * 100
|
||||
item.percentage = percentage.toFixed(1)
|
||||
this.triggerUpload(uploadId)
|
||||
}
|
||||
} else {
|
||||
item.status = 3
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
item.status = 3
|
||||
})
|
||||
},
|
||||
|
||||
// 发送上传消息
|
||||
sendUploadMessage(item: any) {
|
||||
ServeSendTalkFile({
|
||||
upload_id: item.upload_id,
|
||||
receiver_id: item.receiver_id,
|
||||
talk_type: item.talk_type
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
// import { ServeGetUserSetting } from '@/api/user'
|
||||
import { ServeGetUserSetting } from '@/api/user/index.js'
|
||||
// import { ServeFindFriendApplyNum } from '@/api/contact'
|
||||
import { ServeGroupApplyUnread } from '@/api/group'
|
||||
// import { delAccessToken } from '@/utils/auth'
|
||||
@ -38,21 +38,21 @@ export const useUserStore = defineStore('user', {
|
||||
// },
|
||||
|
||||
loadSetting() {
|
||||
// ServeGetUserSetting().then(({ code, data }) => {
|
||||
// if (code == 200) {
|
||||
// this.nickname = data.user_info.nickname
|
||||
// this.uid = data.user_info.uid
|
||||
// this.avatar = data.user_info.avatar
|
||||
ServeGetUserSetting().then(({ code, data }) => {
|
||||
if (code == 200) {
|
||||
this.nickname = data.user_info.nickname
|
||||
this.uid = data.user_info.uid
|
||||
this.avatar = data.user_info.avatar
|
||||
|
||||
// this.gender = data.user_info.gender
|
||||
// this.mobile = data.user_info.mobile || ''
|
||||
// this.email = data.user_info.email || ''
|
||||
// this.motto = data.user_info.motto
|
||||
// this.isQiye = data.user_info.is_qiye || false
|
||||
this.gender = data.user_info.gender
|
||||
this.mobile = data.user_info.mobile || ''
|
||||
this.email = data.user_info.email || ''
|
||||
this.motto = data.user_info.motto
|
||||
this.isQiye = data.user_info.is_qiye || false
|
||||
|
||||
// storage.set('user_info', data)
|
||||
// }
|
||||
// })
|
||||
}
|
||||
})
|
||||
|
||||
// ServeFindFriendApplyNum().then(({ code, data }) => {
|
||||
// if (code == 200) {
|
||||
|
198
src/utils/common.js
Normal file
198
src/utils/common.js
Normal file
@ -0,0 +1,198 @@
|
||||
import { createApp } from 'vue'
|
||||
|
||||
/**
|
||||
* 防抖函数
|
||||
*
|
||||
* @param {*} fn 回调方法
|
||||
* @param {*} delay 延迟时间
|
||||
* @returns
|
||||
*/
|
||||
export function debounce(fn, delay) {
|
||||
let timer = null
|
||||
|
||||
return function () {
|
||||
timer && clearTimeout(timer)
|
||||
|
||||
let content = this
|
||||
let args = arguments
|
||||
timer = setTimeout(() => {
|
||||
fn.apply(content, args)
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 节流函数
|
||||
*
|
||||
* @param {*} fn 回调方法
|
||||
* @param {*} delay 延迟时间
|
||||
* @returns
|
||||
*/
|
||||
export function throttle(fn, delay, call = function () {}) {
|
||||
let lastTime = 0
|
||||
|
||||
return function () {
|
||||
// 获取当前时间戳
|
||||
let now = new Date().getTime()
|
||||
|
||||
// 如果当前时间减去上次时间大于限制时间时才执行
|
||||
if (now - lastTime > delay) {
|
||||
lastTime = now
|
||||
fn.apply(this, arguments)
|
||||
} else {
|
||||
call()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 剪贴板复制功能
|
||||
*
|
||||
* @param {String} text 复制内容
|
||||
* @param {Function} callback 复制成功回调方法
|
||||
*/
|
||||
export function clipboard(text, callback) {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
callback && callback()
|
||||
})
|
||||
.catch(() => {
|
||||
alert('Oops, unable to copy')
|
||||
})
|
||||
}
|
||||
|
||||
export async function clipboardImage(src, callback) {
|
||||
const { state } = await navigator.permissions.query({
|
||||
name: 'clipboard-write'
|
||||
})
|
||||
|
||||
if (state != 'granted') return
|
||||
|
||||
try {
|
||||
const data = await fetch(src)
|
||||
const blob = await data.blob()
|
||||
|
||||
// navigator.clipboard.write 仅支持 png 图片
|
||||
if (blob.type == 'image/png') {
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
[blob.type]: blob
|
||||
})
|
||||
])
|
||||
|
||||
return callback()
|
||||
}
|
||||
|
||||
const objectURL = URL.createObjectURL(blob)
|
||||
|
||||
const img = new Image()
|
||||
img.src = URL.createObjectURL(blob)
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
canvas.width = img.width
|
||||
canvas.height = img.height
|
||||
ctx.drawImage(img, 0, 0)
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
const data = [new ClipboardItem({ [blob.type]: blob })]
|
||||
|
||||
navigator.clipboard
|
||||
.write(data)
|
||||
.then(() => {
|
||||
callback()
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
|
||||
URL.revokeObjectURL(objectURL)
|
||||
},
|
||||
'image/png',
|
||||
1
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err.name, err.message)
|
||||
}
|
||||
}
|
||||
|
||||
export function hashStrToHexColor(str) {
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash)
|
||||
}
|
||||
let color = '#'
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const value = (hash >> (i * 8)) & 0xff
|
||||
color += value.toString(16).padStart(2, '0')
|
||||
}
|
||||
return color
|
||||
}
|
||||
|
||||
export function emitCall(event, data, fn) {
|
||||
return { event: event, data: data, callBack: fn || function () {} }
|
||||
}
|
||||
|
||||
// 判断是否是客户端模式
|
||||
export function isElectronMode() {
|
||||
return electron() != undefined
|
||||
}
|
||||
|
||||
export function electron() {
|
||||
return window.electron
|
||||
}
|
||||
|
||||
export function htmlDecode(input) {
|
||||
return input
|
||||
.replace(/&/g, '&')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
// 文件转 图片 关键函数 异步
|
||||
export function getVideoImage(file, time = 1) {
|
||||
return new Promise((resolve) => {
|
||||
let video = document.createElement('video')
|
||||
|
||||
const objectURL = URL.createObjectURL(file)
|
||||
|
||||
video.src = objectURL
|
||||
video.currentTime = time
|
||||
video.autoplay = true
|
||||
video.muted = true
|
||||
|
||||
video.oncanplay = () => {
|
||||
let canvas = document.createElement('canvas')
|
||||
canvas.width = video.videoWidth
|
||||
canvas.height = video.videoHeight
|
||||
|
||||
let ctx = canvas.getContext('2d')
|
||||
ctx?.drawImage(video, 0, 0, canvas.width, canvas.height)
|
||||
|
||||
let data = {
|
||||
url: canvas.toDataURL('image/jpeg', 1),
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight,
|
||||
duration: video.duration,
|
||||
file: null
|
||||
}
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
data.file = new File([blob], 'image.jpeg', {
|
||||
type: blob.type,
|
||||
lastModified: Date.now()
|
||||
})
|
||||
|
||||
URL.revokeObjectURL(objectURL)
|
||||
|
||||
resolve(data)
|
||||
}, 'image/jpeg')
|
||||
}
|
||||
})
|
||||
}
|
56
src/utils/dom.ts
Normal file
56
src/utils/dom.ts
Normal file
@ -0,0 +1,56 @@
|
||||
function trim(string: string) {
|
||||
return (string || '').replace(/^[\s\uFEFF]+|[\s\uFEFF]+$/g, '')
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
export function hasClass(el: Element, cls: string) {
|
||||
if (!el || !cls) return false
|
||||
if (cls.indexOf(' ') !== -1) throw new Error('className should not contain space.')
|
||||
if (el.classList) {
|
||||
return el.classList.contains(cls)
|
||||
} else {
|
||||
return (' ' + el.className + ' ').indexOf(' ' + cls + ' ') > -1
|
||||
}
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
export function addClass(el: Element, cls: string) {
|
||||
if (!el) return
|
||||
let curClass = el.className
|
||||
const classes = (cls || '').split(' ')
|
||||
|
||||
for (let i = 0, j = classes.length; i < j; i++) {
|
||||
const clsName = classes[i]
|
||||
if (!clsName) continue
|
||||
|
||||
if (el.classList) {
|
||||
el.classList.add(clsName)
|
||||
} else if (!hasClass(el, clsName)) {
|
||||
curClass += ' ' + clsName
|
||||
}
|
||||
}
|
||||
if (!el.classList) {
|
||||
el.className = curClass
|
||||
}
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
export function removeClass(el: Element, cls: string) {
|
||||
if (!el || !cls) return
|
||||
const classes = cls.split(' ')
|
||||
let curClass = ' ' + el.className + ' '
|
||||
|
||||
for (let i = 0, j = classes.length; i < j; i++) {
|
||||
const clsName = classes[i]
|
||||
if (!clsName) continue
|
||||
|
||||
if (el.classList) {
|
||||
el.classList.remove(clsName)
|
||||
} else if (hasClass(el, clsName)) {
|
||||
curClass = curClass.replace(' ' + clsName + ' ', ' ')
|
||||
}
|
||||
}
|
||||
if (!el.classList) {
|
||||
el.className = trim(curClass)
|
||||
}
|
||||
}
|
73
src/utils/storage.ts
Normal file
73
src/utils/storage.ts
Normal file
@ -0,0 +1,73 @@
|
||||
interface IStorage {
|
||||
setItem(key: string, value: any): void
|
||||
getItem(key: string): any
|
||||
removeItem(key: string): void
|
||||
clear(): void
|
||||
}
|
||||
|
||||
class Storage {
|
||||
// 缓存前缀
|
||||
prefix = ''
|
||||
|
||||
// 缓存驱动
|
||||
storage: IStorage = localStorage
|
||||
|
||||
constructor(prefix = '', storage: IStorage) {
|
||||
this.prefix = prefix
|
||||
this.storage = storage
|
||||
}
|
||||
|
||||
cacheKey(key: string) {
|
||||
return `${this.prefix}_${key}`.toUpperCase()
|
||||
}
|
||||
|
||||
get(key: string, def: any = '') {
|
||||
const item = this.storage.getItem(this.cacheKey(key))
|
||||
|
||||
if (!item) return def
|
||||
|
||||
try {
|
||||
const { value, expire } = JSON.parse(item)
|
||||
|
||||
// 在有效期内直接返回
|
||||
if (expire === null || expire >= Date.now()) {
|
||||
return value
|
||||
}
|
||||
|
||||
this.remove(key)
|
||||
} catch (e) {
|
||||
console.warn(e)
|
||||
}
|
||||
|
||||
return def
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置缓存
|
||||
*
|
||||
* @param {String} key // 缓存KEY
|
||||
* @param {Any} value // 缓存值
|
||||
* @param {Number|null} expire // 缓存时间单位秒
|
||||
*/
|
||||
set(key: string, value: any, expire: number | null = 60 * 60 * 24) {
|
||||
this.storage.setItem(
|
||||
this.cacheKey(key),
|
||||
JSON.stringify({
|
||||
value,
|
||||
expire: expire !== null ? new Date().getTime() + expire * 1000 : null
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
remove(key: string) {
|
||||
this.storage.removeItem(this.cacheKey(key))
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.storage.clear()
|
||||
}
|
||||
}
|
||||
|
||||
export default Storage
|
||||
|
||||
export const storage = new Storage('im', localStorage)
|
37
src/utils/upload/auth.js
Normal file
37
src/utils/upload/auth.js
Normal file
@ -0,0 +1,37 @@
|
||||
import { storage } from './storage'
|
||||
|
||||
const AccessToken = 'AUTH_TOKEN'
|
||||
|
||||
/**
|
||||
* 验证是否登录
|
||||
*
|
||||
* @returns token
|
||||
*/
|
||||
export function isLoggedIn() {
|
||||
return getAccessToken() != ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取登录授权 Token
|
||||
*
|
||||
* @returns token
|
||||
*/
|
||||
export function getAccessToken() {
|
||||
return storage.get(AccessToken) || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置登录授权 Token
|
||||
*
|
||||
* @returns token
|
||||
*/
|
||||
export function setAccessToken(token = '', expire = 60 * 60 * 2) {
|
||||
return storage.set(AccessToken, token, expire) || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除登录授权 Token
|
||||
*/
|
||||
export function delAccessToken() {
|
||||
storage.remove(AccessToken)
|
||||
}
|
109
src/utils/upload/request.js
Normal file
109
src/utils/upload/request.js
Normal file
@ -0,0 +1,109 @@
|
||||
import axios from 'axios'
|
||||
import { delAccessToken, getAccessToken } from '@/utils/upload/auth'
|
||||
|
||||
// 创建 axios 实例
|
||||
const request = axios.create({
|
||||
// API 请求的默认前缀
|
||||
baseURL: import.meta.env.VITE_BASE_API,
|
||||
|
||||
// 请求超时时间
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
let once = false
|
||||
|
||||
/**
|
||||
* 异常拦截处理器
|
||||
*
|
||||
* @param {*} error
|
||||
*/
|
||||
const errorHandler = (error) => {
|
||||
// 判断是否是响应错误信息
|
||||
if (error.response) {
|
||||
if (error.response.status == 401) {
|
||||
delAccessToken()
|
||||
|
||||
if (!once) {
|
||||
once = true
|
||||
console.log('当前登录已失效,请重新登录');
|
||||
|
||||
// window['$dialog'].info({
|
||||
// title: '友情提示',
|
||||
// content: '当前登录已失效,请重新登录?',
|
||||
// positiveText: '立即登录?',
|
||||
// maskClosable: false,
|
||||
// onPositiveClick: () => {
|
||||
// location.reload()
|
||||
// }
|
||||
// })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 请求拦截器
|
||||
request.interceptors.request.use((config) => {
|
||||
const token = getAccessToken()
|
||||
|
||||
if (token) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
return config
|
||||
}, errorHandler)
|
||||
|
||||
// 响应拦截器
|
||||
request.interceptors.response.use((response) => response.data, errorHandler)
|
||||
|
||||
/**
|
||||
* GET 请求
|
||||
*
|
||||
* @param {String} url
|
||||
* @param {Object} data
|
||||
* @param {Object} options
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export const get = (url, data = {}, options = {}) => {
|
||||
return request({
|
||||
url,
|
||||
params: data,
|
||||
method: 'get',
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 请求
|
||||
*
|
||||
* @param {String} url
|
||||
* @param {Object} data
|
||||
* @param {Object} options
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export const post = (url, data = {}, options = {}) => {
|
||||
return request({
|
||||
url,
|
||||
method: 'post',
|
||||
data: data,
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件 POST 请求
|
||||
*
|
||||
* @param {String} url
|
||||
* @param {Object} data
|
||||
* @param {Object} options
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export const upload = (url, data = {}, options = {}) => {
|
||||
return request({
|
||||
url,
|
||||
method: 'post',
|
||||
data: data,
|
||||
...options
|
||||
})
|
||||
}
|
73
src/utils/upload/storage.ts
Normal file
73
src/utils/upload/storage.ts
Normal file
@ -0,0 +1,73 @@
|
||||
interface IStorage {
|
||||
setItem(key: string, value: any): void
|
||||
getItem(key: string): any
|
||||
removeItem(key: string): void
|
||||
clear(): void
|
||||
}
|
||||
|
||||
class Storage {
|
||||
// 缓存前缀
|
||||
prefix = ''
|
||||
|
||||
// 缓存驱动
|
||||
storage: IStorage = localStorage
|
||||
|
||||
constructor(prefix = '', storage: IStorage) {
|
||||
this.prefix = prefix
|
||||
this.storage = storage
|
||||
}
|
||||
|
||||
cacheKey(key: string) {
|
||||
return `${this.prefix}_${key}`.toUpperCase()
|
||||
}
|
||||
|
||||
get(key: string, def: any = '') {
|
||||
const item = this.storage.getItem(this.cacheKey(key))
|
||||
|
||||
if (!item) return def
|
||||
|
||||
try {
|
||||
const { value, expire } = JSON.parse(item)
|
||||
|
||||
// 在有效期内直接返回
|
||||
if (expire === null || expire >= Date.now()) {
|
||||
return value
|
||||
}
|
||||
|
||||
this.remove(key)
|
||||
} catch (e) {
|
||||
console.warn(e)
|
||||
}
|
||||
|
||||
return def
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置缓存
|
||||
*
|
||||
* @param {String} key // 缓存KEY
|
||||
* @param {Any} value // 缓存值
|
||||
* @param {Number|null} expire // 缓存时间单位秒
|
||||
*/
|
||||
set(key: string, value: any, expire: number | null = 60 * 60 * 24) {
|
||||
this.storage.setItem(
|
||||
this.cacheKey(key),
|
||||
JSON.stringify({
|
||||
value,
|
||||
expire: expire !== null ? new Date().getTime() + expire * 1000 : null
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
remove(key: string) {
|
||||
this.storage.removeItem(this.cacheKey(key))
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.storage.clear()
|
||||
}
|
||||
}
|
||||
|
||||
export default Storage
|
||||
|
||||
export const storage = new Storage('im', localStorage)
|
@ -23,7 +23,7 @@
|
||||
"./src/*.ts",
|
||||
"./src/*.d.ts",
|
||||
"./src/**/*.ts"
|
||||
, "src/connect.js", "src/store/index.js", "src/store/modules/settings.js", "src/store/modules/user.js", "src/store/modules/uploads.js", "src/store/modules/talk.js", "src/event/talk.js", "src/plugins/ws-socket.js" ],
|
||||
, "src/connect.js", "src/store/index.js", "src/store/modules/settings.js", "src/store/modules/user.js", "src/store/modules/uploads.ts", "src/store/modules/talk.js", "src/event/talk.js", "src/plugins/ws-socket.js", "src/hooks/index.js", "src/hooks/useSessionMenu.js", "src/hooks/useProvideUserModal.js", "src/hooks/useInject.js", "src/hooks/useTalkRecord.js", "src/plugins/index.js", "src/plugins/pinia.js", "src/plugins/component.js" ],
|
||||
"vueCompilerOptions": {
|
||||
"experimentalRuntimeMode": "runtime-uni-app",
|
||||
"nativeTags": ["block", "component", "template", "slot"]
|
||||
|
Loading…
Reference in New Issue
Block a user